【山大会议】应用设置模块

序言

在本篇文章中,我将介绍我对山大会议客户端的设置页面所作的设计。

整体结构

整个设置模块被封装在一个 Setting 模块中,在客户端内将以 Modal 模态屏的形式展示给用户。其整体结构被划分为四个部分:

  • 通用设置
  • 音视频设备
  • 与会状态
  • 关于

每个部分都被细分为独立的模块,便于维护。
整体架构一览

通用设置

下面先来介绍一下通用设置模块,它负责对应用的某些通用功能进行管理。包括是否需要在启动应用时自动登录,是否允许应用开机时自动启动,以及私人视频通话是否开启加密
整个通用设置的模块代码如下:

import { AlertOutlined, LogoutOutlined, QuestionCircleFilled } from '@ant-design/icons';
import { Button, Checkbox, Modal, Tooltip } from 'antd';
import React, { useEffect, useState } from 'react';
import { getMainContent } from 'Utils/Global';
import { eWindow } from 'Utils/Types';

export default function General() {
	const [autoLogin, setAutoLogin] = useState(localStorage.getItem('autoLogin') === 'true');
	const [autoOpen, setAutoOpen] = useState(false);
	const [securityPrivateWebrtc, setSecurityPrivateWebrtc] = useState(
		localStorage.getItem('securityPrivateWebrtc') === 'true'
	);
	useEffect(() => {
		eWindow.ipc.invoke('GET_OPEN_AFTER_START_STATUS').then((status: boolean) => {
			setAutoOpen(status);
		});
	}, []);

	return (
		<>
			<div>
				<Checkbox
					checked={autoLogin}
					onChange={(e) => {
						setAutoLogin(e.target.checked);
						localStorage.setItem('autoLogin', `${e.target.checked}`);
					}}>
					自动登录
				</Checkbox>
			</div>
			<div>
				<Checkbox
					checked={autoOpen}
					onChange={(e) => {
						setAutoOpen(e.target.checked);
						eWindow.ipc.send('EXCHANGE_OPEN_AFTER_START_STATUS', e.target.checked);
					}}>
					开机时启动
				</Checkbox>
			</div>
			<div style={{ display: 'flex' }}>
				<Checkbox
					checked={securityPrivateWebrtc}
					onChange={(e) => {
						if (e.target.checked) {
							Modal.confirm({
								icon: <AlertOutlined />,
								content:
									'开启加密会大幅度提高客户端的CPU占用,请再三确认是否需要开启该功能!',
								cancelText: '暂不开启',
								okText: '确认开启',
								onCancel: () => {},
								onOk: () => {
									setSecurityPrivateWebrtc(true);
									localStorage.setItem('securityPrivateWebrtc', `${true}`);
								},
							});
						} else {
							setSecurityPrivateWebrtc(false);
							localStorage.setItem('securityPrivateWebrtc', `${false}`);
						}
					}}>
					私人加密通话
				</Checkbox>
				<Tooltip placement='right' overlay={'开启加密会大幅度提高CPU占用且不会开启GPU加速'}>
					<QuestionCircleFilled style={{ color: 'gray', transform: 'translateY(25%)' }} />
				</Tooltip>
			</div>
			<div style={{ marginTop: '5px' }}>
				<Button
					icon={<LogoutOutlined />}
					danger
					type='primary'
					onClick={() => {
						Modal.confirm({
							title: '注销',
							content: '你确定要退出当前用户登录吗?',
							icon: <LogoutOutlined />,
							cancelText: '取消',
							okText: '确认',
							okButtonProps: {
								danger: true,
							},
							onOk: () => {
								eWindow.ipc.send('LOG_OUT');
							},
							getContainer: getMainContent,
						});
					}}>
					退出登录
				</Button>
			</div>
		</>
	);
}

其中自动登录功能实现较为简单,我将着重介绍开机自启动功能的实现。

开机时启动

要实现本功能,需要对用户的注册表进行修改。而前端是不具备修改用户注册表的能力的,因此我们需要通过 electron 调用 Node.js 的模块,以实现对用户注册表的操作。
electron 的主进程部分,我们为 ipcMain 添加如下事件柄:

const { app } = require('electron');
const ipc = require('electron').ipcMain;
const cp = require('child_process');

ipc.on('EXCHANGE_OPEN_AFTER_START_STATUS', (evt, openAtLogin) => {
	if (app.isPackaged) {
		if (openAtLogin) {
			cp.exec(
				`REG ADD HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Run /v SduMeeting /t REG_SZ /d "${process.execPath}" /f`,
				(err) => {
					console.log(err);
				}
			);
		} else {
			cp.exec(
				`REG DELETE HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Run /v SduMeeting /f`,
				(err) => {
					console.log(err);
				}
			);
		}
	}
});

ipc.handle('GET_OPEN_AFTER_START_STATUS', () => {
	return new Promise((resolve) => {
		cp.exec(
			`REG QUERY HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Run /v SduMeeting`,
			(err, stdout, stderr) => {
				if (err) {
					resolve(false);
				}
				resolve(stdout.indexOf('SduMeeting') >= 0);
			}
		);
	});
});

两个事件柄分别对应着修改开机启动状态以及获取开机启动状态。我们通过调用 Node.js 的 child_process 模块,通过 COMMAND 语句实现了对 Windows 系统上的注册表的增删改查,并以此实现了修改应用开机时自启动的能力。
需要注意的是,在生产环境下,由于修改注册表是需要管理员权限的,因此在打包时需要为应用申请管理员权限。由于我使用的是 electron-packager 进行打包的,打包时需要在打包命令中多添加一条参数 --win32metadata.requested-execution-level=requireAdministrator

音视频设备

由于本项目的目的是为了让多个用户在线进行视频会议,因此我们必须要为用户维护音视频设备的处理。为了方便维护,我将音频设备视频设备拆分成了两个模块进行管理,在它们上面有一个多媒体设备模块负责管理共享的数据(比如当前的多媒体设备列表以及当前正在使用的设备Id)。

多媒体设备(MediaDevices.tsx)

在这个模块中,我们首先需要提取出用户当前设备连接的所有多媒体设备。要实现这一点,可以利用到我们之前的文章 【山大会议】WebRTC基础之用户媒体的获取 中的内容。
我们先来实现一个获取用户多媒体设备的函数:

/**
 * 获取用户多媒体设备
 */
function getUserMediaDevices() {
	return new Promise((resolve, reject) => {
		try {
			navigator.mediaDevices.enumerateDevices().then((devices) => {
				const generateDeviceJson = (device: MediaDeviceInfo) => {
					const formerIndex = device.label.indexOf(' (');
					const latterIndex = device.label.lastIndexOf(' (');
					const { label, webLabel } = ((label, deviceId) => {
						switch (deviceId) {
							case 'default':
								return {
									label: label.replace('Default - ', ''),
									webLabel: label.replace('Default - ', '默认 - '),
								};
							case 'communications':
								return {
									label: label.replace('Communications - ', ''),
									webLabel: label.replace('Communications - ', '通讯设备 - '),
								};
							default:
								return { label, webLabel: label };
						}
					})(
						formerIndex === latterIndex
							? device.label
							: device.label.substring(0, latterIndex),
						device.deviceId
					);
					return { label, webLabel, deviceId: device.deviceId };
				};
				let videoDevices = [],
					audioDevices = [];
				for (const index in devices) {
					const device = devices[index];
					if (device.kind === 'videoinput') {
						videoDevices.push(generateDeviceJson(device));
					} else if (device.kind === 'audioinput') {
						audioDevices.push(generateDeviceJson(device));
					}
				}
				store.dispatch(updateAvailableDevices(DEVICE_TYPE.VIDEO_DEVICE, videoDevices));
				store.dispatch(updateAvailableDevices(DEVICE_TYPE.AUDIO_DEVICE, audioDevices));
				resolve({ video: videoDevices, audio: audioDevices });
			});
		} catch (error) {
			console.warn('获取设备时发生错误');
			reject(error);
		}
	});
}

通过调用这个函数,我们将获取当前的多媒体设备信息,并将它发送至 Redux 进行状态更新。
整个多媒体设备模块的代码如下:

import { CustomerServiceOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import { globalMessage } from 'Components/GlobalMessage/GlobalMessage';
import React, { useEffect, useState } from 'react';
import { DEVICE_TYPE } from 'Utils/Constraints';
import { updateAvailableDevices } from 'Utils/Store/actions';
import store from 'Utils/Store/store';
import { DeviceInfo } from 'Utils/Types';
import AudioDevices from './AudioDevices';
import VideoDevices from './VideoDevices';

export default function MediaDevices() {
	const [videoDevices, setVideoDevices] = useState(store.getState().availableVideoDevices);
	const [audioDevices, setAudioDevices] = useState(store.getState().availableAudioDevices);
	const [usingVideoDevice, setUsingVideoDevice] = useState('');
	const [usingAudioDevice, setUsingAudioDevice] = useState('');
	useEffect(
		() =>
			store.subscribe(() => {
				const storeState = store.getState();
				setVideoDevices(storeState.availableVideoDevices);
				setAudioDevices(storeState.availableAudioDevices);
				setUsingVideoDevice(`${(storeState.usingVideoDevice as DeviceInfo).webLabel}`);
				setUsingAudioDevice(`${(storeState.usingAudioDevice as DeviceInfo).webLabel}`);
			}),
		[]
	);

	useEffect(() => {
		getUserMediaDevices();
	}, []);

	return (
		<>
			<AudioDevices
				audioDevices={audioDevices}
				usingAudioDevice={usingAudioDevice}
				setUsingAudioDevice={setUsingAudioDevice}
			/>
			<VideoDevices
				videoDevices={videoDevices}
				usingVideoDevice={usingVideoDevice}
				setUsingVideoDevice={setUsingVideoDevice}
			/>
			<Button
				type='link'
				style={{ fontSize: '0.9em' }}
				icon={<CustomerServiceOutlined />}
				onClick={() => {
					getUserMediaDevices().then(() => {
						globalMessage.success('设备信息更新完毕', 0.5);
					});
				}}>
				没找到合适的设备?点我重新获取设备
			</Button>
		</>
	);
}

/**
 * 获取用户多媒体设备
 */
function getUserMediaDevices() {
	return new Promise((resolve, reject) => {
		try {
			navigator.mediaDevices.enumerateDevices().then((devices) => {
				const generateDeviceJson = (device: MediaDeviceInfo) => {
					const formerIndex = device.label.indexOf(' (');
					const latterIndex = device.label.lastIndexOf(' (');
					const { label, webLabel } = ((label, deviceId) => {
						switch (deviceId) {
							case 'default':
								return {
									label: label.replace('Default - ', ''),
									webLabel: label.replace('Default - ', '默认 - '),
								};
							case 'communications':
								return {
									label: label.replace('Communications - ', ''),
									webLabel: label.replace('Communications - ', '通讯设备 - '),
								};
							default:
								return { label, webLabel: label };
						}
					})(
						formerIndex === latterIndex
							? device.label
							: device.label.substring(0, latterIndex),
						device.deviceId
					);
					return { label, webLabel, deviceId: device.deviceId };
				};
				let videoDevices = [],
					audioDevices = [];
				for (const index in devices) {
					const device = devices[index];
					if (device.kind === 'videoinput') {
						videoDevices.push(generateDeviceJson(device));
					} else if (device.kind === 'audioinput') {
						audioDevices.push(generateDeviceJson(device));
					}
				}
				store.dispatch(updateAvailableDevices(DEVICE_TYPE.VIDEO_DEVICE, videoDevices));
				store.dispatch(updateAvailableDevices(DEVICE_TYPE.AUDIO_DEVICE, audioDevices));
				resolve({ video: videoDevices, audio: audioDevices });
			});
		} catch (error) {
			console.warn('获取设备时发生错误');
			reject(error);
		}
	});
}

视频设备(VideoDevices.tsx)

秉持先易后难的原则,我们先绕过音频设备模块,来讲一下视频设备模块。整个模块代码如下:

import { Button, Select } from 'antd';
import React, { useEffect, useRef, useState } from 'react';
import { DEVICE_TYPE } from 'Utils/Constraints';
import eventBus from 'Utils/EventBus/EventBus';
import { getDeviceStream } from 'Utils/Global';
import { exchangeMediaDevice } from 'Utils/Store/actions';
import store from 'Utils/Store/store';
import { DeviceInfo } from 'Utils/Types';

interface VideoDevicesProps {
	videoDevices: Array<DeviceInfo>;
	usingVideoDevice: string;
	setUsingVideoDevice: React.Dispatch<React.SetStateAction<string>>;
}

export default function VideoDevices(props: VideoDevicesProps) {
	const [isExamingCamera, setIsExamingCamera] = useState(false);
	const examCameraRef = useRef<HTMLVideoElement>(null);
	useEffect(() => {
		if (isExamingCamera) {
			videoConnect(examCameraRef);
		} else {
			const examCameraDOM = examCameraRef.current as HTMLVideoElement;
			examCameraDOM.pause();
			examCameraDOM.srcObject = null;
		}
	}, [isExamingCamera]);

	useEffect(() => {
		const onCloseSettingModal = function () {
			setIsExamingCamera(false);
		};
		eventBus.on('CLOSE_SETTING_MODAL', onCloseSettingModal);
		return () => {
			eventBus.off('CLOSE_SETTING_MODAL', onCloseSettingModal);
		};
	}, []);

	return (
		<div>
			请选择录像设备:
			<Select
				placeholder='请选择录像设备'
				style={{ width: '100%' }}
				onSelect={(
					label: string,
					option: { key: string; value: string; children: string }
				) => {
					props.setUsingVideoDevice(label);
					store.dispatch(
						exchangeMediaDevice(DEVICE_TYPE.VIDEO_DEVICE, {
							deviceId: option.key,
							label: option.value,
							webLabel: option.children,
						})
					);
					if (isExamingCamera) {
						videoConnect(examCameraRef);
					}
				}}
				value={props.usingVideoDevice}>
				{props.videoDevices.map((device) => (
					<Select.Option value={device.label} key={device.deviceId}>
						{device.webLabel}
					</Select.Option>
				))}
			</Select>
			<div style={{ margin: '0.25rem' }}>
				<Button
					style={{ width: '7em' }}
					onClick={() => {
						setIsExamingCamera(!isExamingCamera);
					}}>
					{isExamingCamera ? '停止检查' : '检查摄像头'}
				</Button>
			</div>
			<div
				style={{
					width: '100%',
					display: 'flex',
					justifyContent: 'center',
				}}>
				<video
					ref={examCameraRef}
					style={{
						background: 'black',
						width: '40vw',
						height: 'calc(40vw / 1920 * 1080)',
					}}
				/>
			</div>
		</div>
	);
}

async function videoConnect(examCameraRef: React.RefObject<HTMLVideoElement>) {
	const videoStream = await getDeviceStream(DEVICE_TYPE.VIDEO_DEVICE);
	const examCameraDOM = examCameraRef.current as HTMLVideoElement;
	examCameraDOM.srcObject = videoStream;
	examCameraDOM.play();
}

用户可使用本模块更换所需要使用的摄像头,并进行测试。

音频设备(AudioDevices)

音频设备模块所提供的功能与视频设备模块大致相同,但它多包含了测试麦克风音量的功能。在这个应用中,我通过 AudioWorkletNode 实现了麦克风音量的测试。首先需要在 public 下定义一个 worklet 脚本注册进程:

// \public\electronAssets\worklet\volumeMeter.js
/* eslint-disable no-underscore-dangle */
const SMOOTHING_FACTOR = 0.8;
// eslint-disable-next-line no-unused-vars
const MINIMUM_VALUE = 0.00001;
registerProcessor(
	'vumeter',
	class extends AudioWorkletProcessor {
		_volume;
		_updateIntervalInMS;
		_nextUpdateFrame;
		_currentTime;

		constructor() {
			super();
			this._volume = 0;
			this._updateIntervalInMS = 50;
			this._nextUpdateFrame = this._updateIntervalInMS;
			this._currentTime = 0;
			this.port.onmessage = (event) => {
				if (event.data.updateIntervalInMS) {
					this._updateIntervalInMS = event.data.updateIntervalInMS;
					// console.log(event.data.updateIntervalInMS);
				}
			};
		}

		get intervalInFrames() {
			// eslint-disable-next-line no-undef
			return (this._updateIntervalInMS / 1000) * sampleRate;
		}

		process(inputs, outputs, parameters) {
			const input = inputs[0];

			// Note that the input will be down-mixed to mono; however, if no inputs are
			// connected then zero channels will be passed in.
			if (0 < input.length) {
				const samples = input[0];
				let sum = 0;

				// Calculated the squared-sum.
				for (const sample of samples) {
					sum += sample ** 2;
				}

				// Calculate the RMS level and update the volume.
				const rms = Math.sqrt(sum / samples.length);
				this._volume = Math.max(rms, this._volume * SMOOTHING_FACTOR);

				// Update and sync the volume property with the main thread.
				this._nextUpdateFrame -= samples.length;
				if (this._nextUpdateFrame < 0) {
					this._nextUpdateFrame += this.intervalInFrames;
					// const currentTime = currentTime ;
					// eslint-disable-next-line no-undef
					if (!this._currentTime || 0.125 < currentTime - this._currentTime) {
						// eslint-disable-next-line no-undef
						this._currentTime = currentTime;
						// console.log(`currentTime: ${currentTime}`);
						this.port.postMessage({ volume: this._volume });
					}
				}
			}

			return true;
		}
	}
);

在 React 项目中,我使用一个自定义的 Hook 来调用这个 Worklet 脚本,测试音量:

/**
 * 【自定义Hooks】监听媒体流音量
 * @returns 音量、连接流函数、断连函数
 */
const useVolume = () => {
	const [volume, setVolume] = useState(0);
	const ref = useRef({});

	const onmessage = useCallback((evt) => {
		if (!ref.current.audioContext) {
			return;
		}
		if (evt.data.volume) {
			setVolume(Math.round(evt.data.volume * 200));
		}
	}, []);

	const disconnectAudioContext = useCallback(() => {
		if (ref.current.node) {
			try {
				ref.current.node.disconnect();
			} catch (err) {}
		}
		if (ref.current.source) {
			try {
				ref.current.source.disconnect();
			} catch (err) {}
		}
		ref.current.node = null;
		ref.current.source = null;
		ref.current.audioContext = null;
		setVolume(0);
	}, []);

	const connectAudioContext = useCallback(
		async (mediaStream: MediaStream) => {
			if (ref.current.audioContext) {
				disconnectAudioContext();
			}
			try {
				ref.current.audioContext = new AudioContext();
				await ref.current.audioContext.audioWorklet.addModule(
					'../electronAssets/worklet/volumeMeter.js'
				);
				if (!ref.current.audioContext) {
					return;
				}
				ref.current.source = ref.current.audioContext.createMediaStreamSource(mediaStream);
				ref.current.node = new AudioWorkletNode(ref.current.audioContext, 'vumeter');
				ref.current.node.port.onmessage = onmessage;
				ref.current.source
					.connect(ref.current.node)
					.connect(ref.current.audioContext.destination);
			} catch (errMsg) {
				disconnectAudioContext();
			}
		},
		[disconnectAudioContext, onmessage]
	);

	return [volume, connectAudioContext, disconnectAudioContext];
};

整个音频设备模块的源代码如下:

import { Button, Checkbox, Progress, Select } from 'antd';
import { globalMessage } from 'Components/GlobalMessage/GlobalMessage';
import React, { useEffect, useRef, useState } from 'react';
import { DEVICE_TYPE } from 'Utils/Constraints';
import eventBus from 'Utils/EventBus/EventBus';
import { getDeviceStream } from 'Utils/Global';
import { useVolume } from 'Utils/MyHooks/MyHooks';
import { exchangeMediaDevice } from 'Utils/Store/actions';
import store from 'Utils/Store/store';
import { DeviceInfo } from 'Utils/Types';

interface AudioDevicesProps {
	audioDevices: Array<DeviceInfo>;
	usingAudioDevice: string;
	setUsingAudioDevice: React.Dispatch<React.SetStateAction<string>>;
}

export default function AudioDevices(props: AudioDevicesProps) {
	const [isExamingMicroPhone, setIsExamingMicroPhone] = useState(false);
	const [isSoundMeterConnecting, setIsSoundMeterConnecting] = useState(false);
	const examMicroPhoneRef = useRef<HTMLAudioElement>(null);

	const [volume, connectStream, disconnectStream] = useVolume();

	useEffect(() => {
		const examMicroPhoneDOM = examMicroPhoneRef.current as HTMLAudioElement;
		if (isExamingMicroPhone) {
			getDeviceStream(DEVICE_TYPE.AUDIO_DEVICE).then((stream) => {
				connectStream(stream).then(() => {
					globalMessage.success('完成音频设备连接');
					setIsSoundMeterConnecting(false);
				});
				examMicroPhoneDOM.srcObject = stream;
				examMicroPhoneDOM.play();
			});
		} else {
			disconnectStream();
			examMicroPhoneDOM.pause();
		}
	}, [isExamingMicroPhone]);

	useEffect(() => {
		const onCloseSettingModal = function () {
			setIsExamingMicroPhone(false);
			setIsSoundMeterConnecting(false);
		};
		eventBus.on('CLOSE_SETTING_MODAL', onCloseSettingModal);
		return () => {
			eventBus.off('CLOSE_SETTING_MODAL', onCloseSettingModal);
		};
	}, []);

	const [noiseSuppression, setNoiseSuppression] = useState(
		localStorage.getItem('noiseSuppression') !== 'false'
	);
	const [echoCancellation, setEchoCancellation] = useState(
		localStorage.getItem('echoCancellation') !== 'false'
	);

	return (
		<div>
			请选择录音设备:
			<Select
				placeholder='请选择录音设备'
				style={{ width: '100%' }}
				onSelect={(
					label: string,
					option: { key: string; value: string; children: string }
				) => {
					props.setUsingAudioDevice(label);
					store.dispatch(
						exchangeMediaDevice(DEVICE_TYPE.AUDIO_DEVICE, {
							deviceId: option.key,
							label: option.value,
							webLabel: option.children,
						})
					);
					if (isExamingMicroPhone) {
						getDeviceStream(DEVICE_TYPE.AUDIO_DEVICE).then((stream) => {
							connectStream(stream).then(() => {
								globalMessage.success('完成音频设备连接');
								setIsSoundMeterConnecting(false);
							});
							const examMicroPhoneDOM = examMicroPhoneRef.current as HTMLAudioElement;
							examMicroPhoneDOM.pause();
							examMicroPhoneDOM.srcObject = stream;
							examMicroPhoneDOM.play();
						});
					}
				}}
				value={props.usingAudioDevice}>
				{props.audioDevices.map((device) => (
					<Select.Option value={device.label} key={device.deviceId}>
						{device.webLabel}
					</Select.Option>
				))}
			</Select>
			<div style={{ marginTop: '0.25rem', display: 'flex' }}>
				<div style={{ height: '1.2rem' }}>
					<Button
						style={{ width: '7em' }}
						onClick={() => {
							if (!isExamingMicroPhone) setIsSoundMeterConnecting(true);
							setIsExamingMicroPhone(!isExamingMicroPhone);
						}}
						loading={isSoundMeterConnecting}>
						{isExamingMicroPhone ? '停止检查' : '检查麦克风'}
					</Button>
				</div>
				<div style={{ width: '50%', margin: '0.25rem' }}>
					<Progress
						percent={volume}
						showInfo={false}
						strokeColor={
							isExamingMicroPhone ? (volume > 70 ? '#e91013' : '#108ee9') : 'gray'
						}
						size='small'
					/>
				</div>
				<audio ref={examMicroPhoneRef} />
			</div>
			<div style={{ display: 'flex', marginTop: '0.5em' }}>
				<div style={{ fontWeight: 'bold' }}>音频选项:</div>
				<div
					style={{
						display: 'flex',
						justifyContent: 'center',
					}}>
					<Checkbox
						checked={noiseSuppression}
						onChange={(evt) => {
							setNoiseSuppression(evt.target.checked);
							localStorage.setItem('noiseSuppression', `${evt.target.checked}`);
						}}>
						噪音抑制
					</Checkbox>
					<Checkbox
						checked={echoCancellation}
						onChange={(evt) => {
							setEchoCancellation(evt.target.checked);
							localStorage.setItem('echoCancellation', `${evt.target.checked}`);
						}}>
						回声消除
					</Checkbox>
				</div>
			</div>
		</div>
	);
}

除了更换测试麦克风、监听音量,它还允许用户自行选择连线时是否使用噪音抑制回声消除

与会状态

与会状态模块则比较简单,只为用户维护加入会议是否默认开启麦克风和摄像头。代码如下:

import { Checkbox } from 'antd';
import React, { useState } from 'react';

export default function MeetingStatus() {
    const [autoOpenMicroPhone, setAutoOpenMicroPhone] = useState(
        localStorage.getItem('autoOpenMicroPhone') === 'true'
    );
    const [autoOpenCamera, setAutoOpenCamera] = useState(
        localStorage.getItem('autoOpenCamera') === 'true'
    );

    return (
        <>
            <Checkbox
                checked={autoOpenMicroPhone}
                onChange={(e) => {
                    setAutoOpenMicroPhone(e.target.checked);
                    localStorage.setItem('autoOpenMicroPhone', `${e.target.checked}`);
                }}>
                与会时打开麦克风
            </Checkbox>
            <Checkbox
                checked={autoOpenCamera}
                onChange={(e) => {
                    setAutoOpenCamera(e.target.checked);
                    localStorage.setItem('autoOpenCamera', `${e.target.checked}`);
                }}>
                与会时打开摄像头
            </Checkbox>
        </>
    );
}

关于

最后一个模块将展示应用的信息。其最核心的部分在于检测应用是否需要更新,为了实现这一点,首先我写了一个简单的比较版本号的函数。

function needUpdate(nowVersion: string, targetVersion: string) {
	const nowArr = nowVersion.split('.').map((i) => Number(i));
	const newArr = targetVersion.split('.').map((i) => Number(i));
	const lessLength = Math.min(nowArr.length, newArr.length);
	for (let i = 0; i < lessLength; i++) {
		if (nowArr[i] < newArr[i]) {
			return true;
		} else if (nowArr[i] > newArr[i]) {
			return false;
		}
	}
	if (nowArr.length < newArr.length) return true;
	return false;
}

整个关于模块的代码如下:

import { Button, Image, Progress } from 'antd';
import axios from 'axios';
import { globalMessage } from 'Components/GlobalMessage/GlobalMessage';
import React, { useEffect, useMemo, useState } from 'react';
import { eWindow } from 'Utils/Types';
import './style.scss';

function needUpdate(nowVersion: string, targetVersion: string) {
	const nowArr = nowVersion.split('.').map((i) => Number(i));
	const newArr = targetVersion.split('.').map((i) => Number(i));
	const lessLength = Math.min(nowArr.length, newArr.length);
	for (let i = 0; i < lessLength; i++) {
		if (nowArr[i] < newArr[i]) {
			return true;
		} else if (nowArr[i] > newArr[i]) {
			return false;
		}
	}
	if (nowArr.length < newArr.length) return true;
	return false;
}

export default function About() {
	const [appVersion, setAppVersion] = useState<string | undefined>(undefined);
	useEffect(() => {
		eWindow.ipc.invoke('APP_VERSION').then((version: string) => {
			setAppVersion(version);
		});
	}, []);

	const thisYear = useMemo(() => new Date().getFullYear(), []);

	const [latestVersion, setLatestVersion] = useState(false);
	const [checking, setChecking] = useState(false);
	const checkForUpdate = () => {
		setChecking(true);
		axios
			.get('https://assets.aiolia.top/ElectronApps/SduMeeting/manifest.json', {
				headers: {
					'Cache-Control': 'no-cache',
				},
			})
			.then((res) => {
				const { latest } = res.data;
				if (needUpdate(appVersion as string, latest)) setLatestVersion(latest);
				else globalMessage.success({ content: '当前已是最新版本,无需更新' });
			})
			.catch(() => {
				globalMessage.error({
					content: '检查更新失败',
				});
			})
			.finally(() => {
				setChecking(false);
			});
	};

	const [total, setTotal] = useState(Infinity);
	const [loaded, setLoaded] = useState(0);
	const [updating, setUpdating] = useState(false);
	const update = () => {
		setUpdating(true);
		axios
			.get(`https://assets.aiolia.top/ElectronApps/SduMeeting/${latestVersion}/update.zip`, {
				responseType: 'blob',
				onDownloadProgress: (evt) => {
					const { loaded, total } = evt;
					setTotal(total);
					setLoaded(loaded);
				},
				headers: {
					'Cache-Control': 'no-cache',
				},
			})
			.then((res) => {
				const fr = new FileReader();
				fr.onload = () => {
					eWindow.ipc.invoke('DOWNLOADED_UPDATE_ZIP', fr.result).then(() => {
						setTimeout(() => {
							eWindow.ipc.send('READY_TO_UPDATE');
						}, 500);
					});
				};
				fr.readAsBinaryString(res.data);
				globalMessage.success({ content: '更新包下载完毕,即将重启应用...' });
			});
	};

	return (
		<div id='settingAboutContainer'>
			<div>
				<Image
					src={'../electronAssets/favicon177x128.ico'}
					preview={false}
					width={'25%'}
					height={'25%'}
				/>
			</div>
			<div className='settingAboutFaviconText'>山大会议</div>
			<div className='settingAboutFaviconText'>SDU Meeting</div>
			<div id='settingVersionText'>V {appVersion}</div>
			{latestVersion ? (
				<>
					<div>检查到有新的可用版本:V {latestVersion},是否进行更新?</div>
					{updating ? (
						<>
							<Progress
								percent={Number(((loaded / total) * 100).toFixed(0))}
								status={loaded === total ? 'success' : 'active'}
							/>
						</>
					) : (
						<Button onClick={update}>开始下载</Button>
					)}
				</>
			) : (
				<Button type='primary' onClick={checkForUpdate} loading={checking}>
					检查更新
				</Button>
			)}
			<div id='copyright'>Copyright (c) 2021{thisYear ? ` - ${thisYear}` : ''} 德布罗煜</div>
		</div>
	);
}

设置-关于
当应用检测到新版本后,将会以 Blob 的形式下载最新的版本更新包,下载完成后,将会通过我在 electron 中编写的函数将更新包保存在特定的位置。

const ipc = require('electron').ipcMain;
const fs = require('fs-extra');

ipc.handle('DOWNLOADED_UPDATE_ZIP', (evt, data) => {
	fs.writeFileSync(path.join(EXEPATH, 'resources', 'update.zip'), data, 'binary');
	return true;
});

由于在应用开启的时候,更新包需要替换的部分文件处于占用状态,因此我在 electron 中写了另一个函数,用以开启一个独立于 山大会议 应用本身的子进程,在山大会议自动关闭后,调用我用 C++ 写的一个更新(解压)程序,将更新包的内容提取出来覆盖掉旧的文件,从而实现应用的更新。

// electron 中的更新进程
const { app } = require('electron');
const cp = require('child_process');

function readyToUpdate() {
	const { spawn } = cp;
	const child = spawn(
		path.join(EXEPATH, 'resources/ReadyUpdater.exe'),
		['YES_I_WANNA_UPDATE_ASAR'],
		{
			detached: true,
			shell: true,
		}
	);
	if (mainWindow) mainWindow.close();
	child.unref();
	app.quit();
}
// ReadyUpdater.cpp

#include <iostream>
#include <stdlib.h>
#include <tchar.h>
#include <Windows.h>
#include "unzip.h"
using namespace std;

int main(int argc, char* argv[])
{
	Sleep(300);
	if (argc < 2) {
		cout << "您正以不当方式运行该程序" << endl;
	}
	else {
		char* safetyKey = argv[1];
		if (strcmp("YES_I_WANNA_UPDATE_ASAR", safetyKey) != 0) {
			cout << "你不应当执行该程序" << endl;
		}
		else {
			HZIP hz = OpenZip(_T(".\\resources\\update.zip"), 0);
			SetUnzipBaseDir(hz, _T(".\\resources"));
			ZIPENTRY ze;
			GetZipItem(hz, -1, &ze);
			int numitems = ze.index;
			// -1 gives overall information about the zipfile
			for (int zi = 0; zi < numitems; zi++)
			{
				ZIPENTRY ze;
				GetZipItem(hz, zi, &ze); // fetch individual details
				UnzipItem(hz, zi, ze.name);         // e.g. the item's name.
			}
			CloseZip(hz);
			system("del .\\resources\\update.zip");
			cout << "更新完成" << endl;
			cout << "请重启应用" << endl;
		}
	}
	system("pause");
	return 0;
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小栗帽今天吃什么

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值