diff --git a/app/internal_packages/custom-sounds/lib/notification-sound.ts b/app/internal_packages/custom-sounds/lib/notification-sound.ts new file mode 100644 index 0000000000..d849439af7 --- /dev/null +++ b/app/internal_packages/custom-sounds/lib/notification-sound.ts @@ -0,0 +1,50 @@ +import fs from 'fs'; +import path from 'path'; +import { pathToFileURL } from 'url'; + +export const NOTIFICATION_SOUND_VOLUME_CONFIG_KEY = 'core.notifications.soundVolume'; +export const CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY = 'core.notifications.customSoundPath'; +export const SUPPORTED_NOTIFICATION_SOUND_EXTENSIONS = ['mp3', 'ogg', 'wav', 'm4a', 'aac', 'flac']; +export const MAX_CUSTOM_NOTIFICATION_SOUND_BYTES = 25 * 1024 * 1024; + +type FileStats = { size: number; isFile: () => boolean }; +type StatFile = (candidate: string) => FileStats; + +export function isSupportedNotificationSoundPath(filePath: unknown): filePath is string { + if (typeof filePath !== 'string' || !filePath) return false; + const extension = path.extname(filePath).slice(1).toLowerCase(); + return SUPPORTED_NOTIFICATION_SOUND_EXTENSIONS.includes(extension); +} + +export function resolveCustomNotificationSound( + filePath: unknown, + statFile: StatFile = fs.statSync +): string | undefined { + if (!isSupportedNotificationSoundPath(filePath) || !path.isAbsolute(filePath)) return undefined; + + try { + const stats = statFile(filePath); + if (!stats.isFile() || stats.size < 1 || stats.size > MAX_CUSTOM_NOTIFICATION_SOUND_BYTES) { + return undefined; + } + } catch (error) { + return undefined; + } + + return pathToFileURL(path.resolve(filePath)).toString(); +} + +export function notificationSoundPlaybackOptions( + config: { get: (key: string) => any }, + statFile: StatFile = fs.statSync +) { + const volumePercent = Number(config.get(NOTIFICATION_SOUND_VOLUME_CONFIG_KEY)); + const source = resolveCustomNotificationSound( + config.get(CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY), + statFile + ); + return { + volume: volumePercent / 100, + ...(source ? { source } : {}), + }; +} diff --git a/app/internal_packages/custom-sounds/specs/notification-sound-spec.ts b/app/internal_packages/custom-sounds/specs/notification-sound-spec.ts new file mode 100644 index 0000000000..ce6f516075 --- /dev/null +++ b/app/internal_packages/custom-sounds/specs/notification-sound-spec.ts @@ -0,0 +1,79 @@ +import path from 'path'; +import { pathToFileURL } from 'url'; +import { + CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY, + MAX_CUSTOM_NOTIFICATION_SOUND_BYTES, + NOTIFICATION_SOUND_VOLUME_CONFIG_KEY, + isSupportedNotificationSoundPath, + notificationSoundPlaybackOptions, + resolveCustomNotificationSound, +} from '../lib/notification-sound'; + +describe('notification sound settings', () => { + const regularFile = + (size = 1024) => + () => ({ size, isFile: () => true }); + + it('recognizes supported extensions case-insensitively', () => { + expect(isSupportedNotificationSoundPath('/sounds/mail.ogg')).toBe(true); + expect(isSupportedNotificationSoundPath('/sounds/mail.MP3')).toBe(true); + expect(isSupportedNotificationSoundPath('/sounds/mail.txt')).toBe(false); + expect(isSupportedNotificationSoundPath('')).toBe(false); + }); + + it('resolves an existing supported file to a file URL', () => { + const filePath = path.resolve('/sounds/mail tone.ogg'); + expect(resolveCustomNotificationSound(filePath, regularFile())).toBe( + pathToFileURL(filePath).toString() + ); + }); + + it('falls back for missing, unsupported, and relative files', () => { + const missingFile = () => { + throw new Error('ENOENT'); + }; + expect( + resolveCustomNotificationSound(path.resolve('/sounds/missing.ogg'), missingFile) + ).toBeUndefined(); + expect( + resolveCustomNotificationSound(path.resolve('/sounds/mail.txt'), regularFile()) + ).toBeUndefined(); + expect(resolveCustomNotificationSound('sounds/mail.ogg', regularFile())).toBeUndefined(); + expect(resolveCustomNotificationSound(undefined, regularFile())).toBeUndefined(); + }); + + it('falls back for non-regular, empty, and oversized files', () => { + const directory = () => ({ size: 1024, isFile: () => false }); + const filePath = path.resolve('/sounds/mail.ogg'); + + expect(resolveCustomNotificationSound(filePath, directory)).toBeUndefined(); + expect(resolveCustomNotificationSound(filePath, regularFile(0))).toBeUndefined(); + expect( + resolveCustomNotificationSound(filePath, regularFile(MAX_CUSTOM_NOTIFICATION_SOUND_BYTES + 1)) + ).toBeUndefined(); + expect( + resolveCustomNotificationSound(filePath, regularFile(MAX_CUSTOM_NOTIFICATION_SOUND_BYTES)) + ).toBe(pathToFileURL(filePath).toString()); + }); + + it('builds playback options from independent volume and custom-path preferences', () => { + const customPath = path.resolve('/sounds/mail.ogg'); + const config = { + get: (key) => { + if (key === NOTIFICATION_SOUND_VOLUME_CONFIG_KEY) return 25; + if (key === CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY) return customPath; + return undefined; + }, + }; + + expect(notificationSoundPlaybackOptions(config, regularFile())).toEqual({ + volume: 0.25, + source: pathToFileURL(customPath).toString(), + }); + expect( + notificationSoundPlaybackOptions(config, () => { + throw new Error('ENOENT'); + }) + ).toEqual({ volume: 0.25 }); + }); +}); diff --git a/app/internal_packages/preferences/lib/tabs/config-schema-item.tsx b/app/internal_packages/preferences/lib/tabs/config-schema-item.tsx index eace53e7bc..34e7096645 100644 --- a/app/internal_packages/preferences/lib/tabs/config-schema-item.tsx +++ b/app/internal_packages/preferences/lib/tabs/config-schema-item.tsx @@ -39,6 +39,10 @@ class ConfigSchemaItem extends React.Component { event.target.blur(); }; + _onChangeNumber = (event) => { + this.props.config.set(this.props.keyPath, Number(event.target.value)); + }; + render() { if (!this._appliesToPlatform()) return false; @@ -100,6 +104,30 @@ class ConfigSchemaItem extends React.Component { {note} ); + } else if ( + this.props.configSchema.type === 'number' && + this.props.configSchema.minimum !== undefined && + this.props.configSchema.maximum !== undefined + ) { + const value = Number(this.props.config.get(this.props.keyPath)); + return ( +
+ + + {note} +
+ ); } return ; } diff --git a/app/internal_packages/preferences/lib/tabs/notifications-section.tsx b/app/internal_packages/preferences/lib/tabs/notifications-section.tsx new file mode 100644 index 0000000000..e67e07af36 --- /dev/null +++ b/app/internal_packages/preferences/lib/tabs/notifications-section.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import path from 'path'; +import { localized, SoundRegistry } from 'mailspring-exports'; +import ConfigSchemaItem from './config-schema-item'; +import { ConfigLike, ConfigSchemaLike } from '../types'; +import { + CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY, + SUPPORTED_NOTIFICATION_SOUND_EXTENSIONS, + isSupportedNotificationSoundPath, + notificationSoundPlaybackOptions, + resolveCustomNotificationSound, +} from '../../../custom-sounds/lib/notification-sound'; + +export default class NotificationsSection extends React.Component<{ + config: ConfigLike; + configSchema: ConfigSchemaLike; +}> { + _chooseSound = () => { + AppEnv.showOpenDialog( + { + title: localized('Choose a notification sound'), + buttonLabel: localized('Choose'), + properties: ['openFile'], + filters: [ + { + name: localized('Audio files'), + extensions: SUPPORTED_NOTIFICATION_SOUND_EXTENSIONS, + }, + ], + }, + (paths) => { + if (!paths || paths.length === 0) return; + if (!isSupportedNotificationSoundPath(paths[0])) { + AppEnv.showErrorDialog( + localized( + 'Please choose an audio file with one of these extensions: %@', + SUPPORTED_NOTIFICATION_SOUND_EXTENSIONS.join(', ') + ) + ); + return; + } + this.props.config.set(CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY, paths[0]); + } + ); + }; + + _resetSound = () => { + this.props.config.set(CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY, ''); + }; + + _playTestSound = () => { + SoundRegistry.playSound('new-mail', notificationSoundPlaybackOptions(this.props.config)); + }; + + render() { + const customPath = this.props.config.get(CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY); + const customSource = resolveCustomNotificationSound(customPath); + return ( +
+ +
+
+ {customPath + ? customSource + ? localized('Custom sound: %@', path.basename(customPath)) + : localized('Custom sound unavailable; using the default sound') + : localized('Using the default notification sound')} +
+ + {customPath && ( + + )} + +
+
+ ); + } +} diff --git a/app/internal_packages/preferences/lib/tabs/preferences-general.tsx b/app/internal_packages/preferences/lib/tabs/preferences-general.tsx index 4d78a02f16..47c17c535e 100644 --- a/app/internal_packages/preferences/lib/tabs/preferences-general.tsx +++ b/app/internal_packages/preferences/lib/tabs/preferences-general.tsx @@ -6,6 +6,7 @@ import ConfigSchemaItem from './config-schema-item'; import WorkspaceSection from './workspace-section'; import SendingSection from './sending-section'; import LanguageSection from './language-section'; +import NotificationsSection from './notifications-section'; import { ConfigLike, ConfigSchemaLike } from '../types'; class PreferencesGeneral extends React.Component<{ @@ -95,10 +96,8 @@ class PreferencesGeneral extends React.Component<{
-
diff --git a/app/internal_packages/preferences/lib/types.ts b/app/internal_packages/preferences/lib/types.ts index bb4846c35c..11c4e0bde3 100644 --- a/app/internal_packages/preferences/lib/types.ts +++ b/app/internal_packages/preferences/lib/types.ts @@ -1,5 +1,5 @@ export interface ConfigLike { - get: (key: string) => string; + get: (key: string) => any; toggle: (key: string) => void; set: (key: string, val: any) => void; } @@ -15,4 +15,9 @@ export interface ConfigSchemaLike { enum?: string; enumLabels: string; platforms?: string[]; + platform?: string; + minimum?: number; + maximum?: number; + multipleOf?: number; + unit?: string; } diff --git a/app/internal_packages/preferences/specs/config-schema-item-spec.tsx b/app/internal_packages/preferences/specs/config-schema-item-spec.tsx new file mode 100644 index 0000000000..fdea34f62c --- /dev/null +++ b/app/internal_packages/preferences/specs/config-schema-item-spec.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { cleanup, fireEvent, render } from '@testing-library/react'; +import ConfigSchemaItem from '../lib/tabs/config-schema-item'; + +describe('ConfigSchemaItem', () => { + afterEach(cleanup); + + it('renders and persists a bounded numeric range value as a number', () => { + const config = { + get: jasmine.createSpy('get').andReturn(65), + set: jasmine.createSpy('set'), + toggle: jasmine.createSpy('toggle'), + }; + const configSchema = { + type: 'number', + title: 'Notification sound volume', + minimum: 0, + maximum: 100, + multipleOf: 1, + unit: '%', + } as any; + + const { container } = render( + + ); + + const range = container.querySelector('input[type="range"]') as HTMLInputElement; + expect(range.min).toBe('0'); + expect(range.max).toBe('100'); + expect(range.value).toBe('65'); + expect(container.querySelector('output').textContent).toBe('65%'); + + fireEvent.change(range, { target: { value: '40' } }); + expect(config.set).toHaveBeenCalledWith('core.notifications.soundVolume', 40); + }); +}); diff --git a/app/internal_packages/preferences/specs/notifications-section-spec.tsx b/app/internal_packages/preferences/specs/notifications-section-spec.tsx new file mode 100644 index 0000000000..f6dcbe92a4 --- /dev/null +++ b/app/internal_packages/preferences/specs/notifications-section-spec.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { cleanup, fireEvent, render } from '@testing-library/react'; +import { SoundRegistry } from 'mailspring-exports'; +import NotificationsSection from '../lib/tabs/notifications-section'; +import { + CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY, + NOTIFICATION_SOUND_VOLUME_CONFIG_KEY, +} from '../../custom-sounds/lib/notification-sound'; + +describe('NotificationsSection', () => { + afterEach(cleanup); + + const configSchema = { + type: 'object', + properties: { + sounds: { type: 'boolean', title: 'Play sound' }, + soundVolume: { + type: 'number', + title: 'Volume', + minimum: 0, + maximum: 100, + multipleOf: 1, + unit: '%', + }, + customSoundPath: { type: 'string', advanced: true }, + }, + } as any; + + it('selects and resets a supported custom sound', () => { + let customPath = ''; + const config = { + get: jasmine.createSpy('get').andCallFake((key) => { + if (key === CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY) return customPath; + if (key === NOTIFICATION_SOUND_VOLUME_CONFIG_KEY) return 100; + if (key === 'core.notifications.sounds') return true; + return undefined; + }), + set: jasmine.createSpy('set').andCallFake((key, value) => { + if (key === CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY) customPath = value; + }), + toggle: jasmine.createSpy('toggle'), + }; + spyOn(AppEnv, 'showOpenDialog').andCallFake((options, callback) => { + callback(['/sounds/mail.ogg']); + }); + const { getByText } = render( + + ); + + fireEvent.click(getByText('Choose Sound…')); + expect(config.set).toHaveBeenCalledWith( + CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY, + '/sounds/mail.ogg' + ); + + const resetView = render(); + fireEvent.click(resetView.getByText('Reset to Default')); + expect(config.set).toHaveBeenCalledWith(CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY, ''); + }); + + it('previews the notification sound with the configured volume', () => { + const config = { + get: jasmine.createSpy('get').andCallFake((key) => { + if (key === NOTIFICATION_SOUND_VOLUME_CONFIG_KEY) return 40; + if (key === CUSTOM_NOTIFICATION_SOUND_CONFIG_KEY) return ''; + if (key === 'core.notifications.sounds') return true; + return undefined; + }), + set: jasmine.createSpy('set'), + toggle: jasmine.createSpy('toggle'), + }; + spyOn(SoundRegistry, 'playSound'); + const { getByText } = render( + + ); + + fireEvent.click(getByText('Play Test Sound')); + expect(SoundRegistry.playSound).toHaveBeenCalledWith('new-mail', { volume: 0.4 }); + }); +}); diff --git a/app/internal_packages/preferences/styles/preferences.less b/app/internal_packages/preferences/styles/preferences.less index 046ca4edc6..63290f015f 100644 --- a/app/internal_packages/preferences/styles/preferences.less +++ b/app/internal_packages/preferences/styles/preferences.less @@ -16,6 +16,35 @@ margin: 4px 0 0 0; } + .config-schema-range-item { + label, + input[type='range'] { + display: block; + width: 100%; + } + + output { + float: right; + color: @text-color-very-subtle; + } + } + + .notification-sound-controls { + padding-top: @padding-small-vertical; + + .notification-sound-file { + margin-bottom: @padding-small-vertical; + color: @text-color-very-subtle; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .btn { + margin: 0 @padding-small-horizontal @padding-small-vertical 0; + } + } + height: 100%; background-color: @background-primary; color: @text-color; diff --git a/app/internal_packages/unread-notifications/lib/main.ts b/app/internal_packages/unread-notifications/lib/main.ts index fda1780251..d846550794 100644 --- a/app/internal_packages/unread-notifications/lib/main.ts +++ b/app/internal_packages/unread-notifications/lib/main.ts @@ -11,6 +11,7 @@ import { DatabaseChangeRecord, TaskFactory, } from 'mailspring-exports'; +import { notificationSoundPlaybackOptions } from '../../custom-sounds/lib/notification-sound'; const WAIT_FOR_CHANGES_DELAY = 400; @@ -236,7 +237,7 @@ export class Notifier { _playNewMailSound = _.debounce( () => { if (!AppEnv.config.get('core.notifications.sounds')) return; - SoundRegistry.playSound('new-mail'); + SoundRegistry.playSound('new-mail', notificationSoundPlaybackOptions(AppEnv.config)); }, 5000, true diff --git a/app/internal_packages/unread-notifications/specs/main-spec.es6 b/app/internal_packages/unread-notifications/specs/main-spec.es6 index 4d2d7fef77..3bb6188ab8 100644 --- a/app/internal_packages/unread-notifications/specs/main-spec.es6 +++ b/app/internal_packages/unread-notifications/specs/main-spec.es6 @@ -445,6 +445,7 @@ describe('UnreadNotifications', function UnreadNotifications() { spyOn(AppEnv.config, 'get').andCallFake(config => { if (config === 'core.notifications.enabled') return true; if (config === 'core.notifications.sounds') return true; + if (config === 'core.notifications.soundVolume') return 35; return undefined; }); @@ -456,7 +457,7 @@ describe('UnreadNotifications', function UnreadNotifications() { objectsRawJSON: getObjectsRawJson(['1']) }); expect(AppEnv.config.get.calls[1].args[0]).toBe('core.notifications.sounds'); - expect(SoundRegistry.playSound).toHaveBeenCalledWith('new-mail'); + expect(SoundRegistry.playSound).toHaveBeenCalledWith('new-mail', { volume: 0.35 }); }); }); diff --git a/app/spec/content-security-policy-spec.ts b/app/spec/content-security-policy-spec.ts new file mode 100644 index 0000000000..7fc75bc855 --- /dev/null +++ b/app/spec/content-security-policy-spec.ts @@ -0,0 +1,15 @@ +import fs from 'fs'; +import path from 'path'; + +describe('main window Content Security Policy', () => { + it('allows local files as media without allowing them as scripts', () => { + const { resourcePath } = AppEnv.getLoadSettings(); + const indexHtml = fs.readFileSync(path.join(resourcePath, 'static', 'index.html'), 'utf8'); + const browserMain = fs.readFileSync(path.join(resourcePath, 'src', 'browser', 'main.js'), 'utf8'); + + for (const policySource of [indexHtml, browserMain]) { + expect(policySource).toContain('media-src mailspring: file:'); + expect(policySource).not.toContain("script-src 'self' file:"); + } + }); +}); diff --git a/app/spec/native-notifications-spec.ts b/app/spec/native-notifications-spec.ts new file mode 100644 index 0000000000..0d45df2f4c --- /dev/null +++ b/app/spec/native-notifications-spec.ts @@ -0,0 +1,23 @@ +import NativeNotifications from '../src/native-notifications'; + +describe('NativeNotifications Windows toast XML', () => { + it('silences individual notification toasts so SoundRegistry controls audio', () => { + const xml = (NativeNotifications as any).buildWindowsToastXml({ + id: 'notification-id', + title: 'New message', + threadId: 'thread-id', + }); + + expect(xml).toContain('