diff --git a/package.json b/package.json index f8b4287197b..a4b425d0ccf 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", "matrix-widget-api": "^0.1.0-beta.13", "minimist": "^1.2.5", + "opus-recorder": "^8.0.3", "pako": "^2.0.3", "parse5": "^6.0.1", "png-chunks-extract": "^1.0.0", diff --git a/res/css/_components.scss b/res/css/_components.scss index d894688cacf..9c895490b36 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -111,8 +111,8 @@ @import "./views/elements/_AddressSelector.scss"; @import "./views/elements/_AddressTile.scss"; @import "./views/elements/_DesktopBuildsNotice.scss"; -@import "./views/elements/_DirectorySearchBox.scss"; @import "./views/elements/_DesktopCapturerSourcePicker.scss"; +@import "./views/elements/_DirectorySearchBox.scss"; @import "./views/elements/_Dropdown.scss"; @import "./views/elements/_EditableItemList.scss"; @import "./views/elements/_ErrorBoundary.scss"; @@ -211,13 +211,13 @@ @import "./views/rooms/_SendMessageComposer.scss"; @import "./views/rooms/_Stickers.scss"; @import "./views/rooms/_TopUnreadMessagesBar.scss"; +@import "./views/rooms/_VoiceRecordComposerTile.scss"; @import "./views/rooms/_WhoIsTypingTile.scss"; @import "./views/settings/_AvatarSetting.scss"; @import "./views/settings/_CrossSigningPanel.scss"; @import "./views/settings/_DevicesPanel.scss"; @import "./views/settings/_E2eAdvancedPanel.scss"; @import "./views/settings/_EmailAddresses.scss"; -@import "./views/settings/_SpellCheckLanguages.scss"; @import "./views/settings/_IntegrationManager.scss"; @import "./views/settings/_Notifications.scss"; @import "./views/settings/_PhoneNumbers.scss"; @@ -225,6 +225,7 @@ @import "./views/settings/_SecureBackupPanel.scss"; @import "./views/settings/_SetIdServer.scss"; @import "./views/settings/_SetIntegrationManager.scss"; +@import "./views/settings/_SpellCheckLanguages.scss"; @import "./views/settings/_UpdateCheckButton.scss"; @import "./views/settings/tabs/_SettingsTab.scss"; @import "./views/settings/tabs/room/_GeneralRoomSettingsTab.scss"; diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss index e126e523a6e..4f58c08617d 100644 --- a/res/css/views/rooms/_BasicMessageComposer.scss +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -66,6 +66,11 @@ limitations under the License. } } } + + &.mx_BasicMessageComposer_input_disabled { + pointer-events: none; + cursor: not-allowed; + } } .mx_BasicMessageComposer_AutoCompleteWrapper { diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index dea1b58741c..e6c0cc3f464 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -227,6 +227,10 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); } +.mx_MessageComposer_voiceMessage::before { + mask-image: url('$(res)/img/voip/mic-on-mask.svg'); +} + .mx_MessageComposer_emoji::before { mask-image: url('$(res)/img/element-icons/room/composer/emoji.svg'); } diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.scss b/res/css/views/rooms/_VoiceRecordComposerTile.scss new file mode 100644 index 00000000000..bb36991b4fa --- /dev/null +++ b/res/css/views/rooms/_VoiceRecordComposerTile.scss @@ -0,0 +1,36 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_VoiceRecordComposerTile_stop { + // 28px plus a 2px border makes this a 32px square (as intended) + width: 28px; + height: 28px; + border: 2px solid $voice-record-stop-border-color; + border-radius: 32px; + margin-right: 16px; // between us and the send button + position: relative; + + &::after { + content: ''; + width: 14px; + height: 14px; + position: absolute; + top: 7px; + left: 7px; + border-radius: 2px; + background-color: $voice-record-stop-symbol-color; + } +} diff --git a/res/img/voip/mic-on-mask.svg b/res/img/voip/mic-on-mask.svg new file mode 100644 index 00000000000..418316b1642 --- /dev/null +++ b/res/img/voip/mic-on-mask.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index 9ad154dd93e..d7ee496d809 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -189,6 +189,9 @@ $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%) $groupFilterPanel-divider-color: $roomlist-header-color; $space-button-outline-color: #E3E8F0; +$voice-record-stop-border-color: #E3E8F0; +$voice-record-stop-symbol-color: $warning-color; + $roomtile-preview-color: #9e9e9e; $roomtile-default-badge-bg-color: #61708b; $roomtile-selected-bg-color: #fff; diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 25fbd0201b1..577204ef0cf 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -180,6 +180,9 @@ $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%) $groupFilterPanel-divider-color: $roomlist-header-color; $space-button-outline-color: #E3E8F0; +$voice-record-stop-border-color: #E3E8F0; +$voice-record-stop-symbol-color: $warning-color; + $roomtile-preview-color: $secondary-fg-color; $roomtile-default-badge-bg-color: #61708b; $roomtile-selected-bg-color: #FFF; diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 4aa6df54889..051e5cc4299 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -39,6 +39,7 @@ import {ModalWidgetStore} from "../stores/ModalWidgetStore"; import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; import VoipUserMapper from "../VoipUserMapper"; import {SpaceStoreClass} from "../stores/SpaceStore"; +import {VoiceRecorder} from "../voice/VoiceRecorder"; declare global { interface Window { @@ -70,6 +71,7 @@ declare global { mxModalWidgetStore: ModalWidgetStore; mxVoipUserMapper: VoipUserMapper; mxSpaceStore: SpaceStoreClass; + mxVoiceRecorder: typeof VoiceRecorder; } interface Document { diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js index 866e0f521d0..28c2f8f9b9b 100644 --- a/src/components/views/messages/MessageEvent.js +++ b/src/components/views/messages/MessageEvent.js @@ -71,6 +71,10 @@ export default class MessageEvent extends React.Component { 'm.file': sdk.getComponent('messages.MFileBody'), 'm.audio': sdk.getComponent('messages.MAudioBody'), 'm.video': sdk.getComponent('messages.MVideoBody'), + + // TODO: @@ TravisR: Use labs flag determination. + // MSC: https://github.com/matrix-org/matrix-doc/pull/2516 + 'org.matrix.msc2516.voice': sdk.getComponent('messages.MAudioBody'), }; const evTypes = { 'm.sticker': sdk.getComponent('messages.MStickerBody'), diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 5ab2b82a326..1a95b4366a1 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -93,6 +93,7 @@ interface IProps { placeholder?: string; label?: string; initialCaret?: DocumentOffset; + disabled?: boolean; onChange?(); onPaste?(event: ClipboardEvent, model: EditorModel): boolean; @@ -672,6 +673,9 @@ export default class BasicMessageEditor extends React.Component }); const classes = classNames("mx_BasicMessageComposer_input", { "mx_BasicMessageComposer_input_shouldShowPillAvatar": this.state.showPillAvatar, + + // TODO: @@ TravisR: This doesn't work properly. The composer resets in a strange way. + "mx_BasicMessageComposer_input_disabled": this.props.disabled, }); const shortcuts = { @@ -704,6 +708,7 @@ export default class BasicMessageEditor extends React.Component aria-expanded={Boolean(this.state.autoComplete)} aria-activedescendant={completionIndex >= 0 ? generateCompletionDomId(completionIndex) : undefined} dir="auto" + aria-disabled={this.props.disabled} /> ); } diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index ccf097c4fd5..b7078766fb5 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -33,6 +33,7 @@ import WidgetStore from "../../../stores/WidgetStore"; import {UPDATE_EVENT} from "../../../stores/AsyncStore"; import ActiveWidgetStore from "../../../stores/ActiveWidgetStore"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import VoiceRecordComposerTile from "./VoiceRecordComposerTile"; function ComposerAvatar(props) { const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar'); @@ -187,6 +188,7 @@ export default class MessageComposer extends React.Component { hasConference: WidgetStore.instance.doesRoomHaveConference(this.props.room), joinedConference: WidgetStore.instance.isJoinedToConferenceIn(this.props.room), isComposerEmpty: true, + haveRecording: false, }; } @@ -325,6 +327,10 @@ export default class MessageComposer extends React.Component { }); } + onVoiceUpdate = (haveRecording: boolean) => { + this.setState({haveRecording}); + }; + render() { const controls = [ this.state.me ? : null, @@ -346,17 +352,32 @@ export default class MessageComposer extends React.Component { permalinkCreator={this.props.permalinkCreator} replyToEvent={this.props.replyToEvent} onChange={this.onChange} + // TODO: @@ TravisR - Disabling the composer doesn't work + disabled={this.state.haveRecording} />, - , - , ); + if (!this.state.haveRecording) { + controls.push( + , + , + ); + } + if (SettingsStore.getValue(UIFeature.Widgets) && - SettingsStore.getValue("MessageComposerInput.showStickersButton")) { + SettingsStore.getValue("MessageComposerInput.showStickersButton") && + !this.state.haveRecording) { controls.push(); } - if (!this.state.isComposerEmpty) { + if (SettingsStore.getValue("feature_voice_messages")) { + controls.push(); + } + + if (!this.state.isComposerEmpty || this.state.haveRecording) { controls.push( , ); diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index ba3076c07d3..aed1bb36fef 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -120,6 +120,7 @@ export default class SendMessageComposer extends React.Component { permalinkCreator: PropTypes.object.isRequired, replyToEvent: PropTypes.object, onChange: PropTypes.func, + disabled: PropTypes.bool, }; static contextType = MatrixClientContext; @@ -556,6 +557,7 @@ export default class SendMessageComposer extends React.Component { label={this.props.placeholder} placeholder={this.props.placeholder} onPaste={this._onPaste} + disabled={this.props.disabled} /> ); diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx new file mode 100644 index 00000000000..0d381001a11 --- /dev/null +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -0,0 +1,88 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; +import {_t} from "../../../languageHandler"; +import React from "react"; +import {VoiceRecorder} from "../../../voice/VoiceRecorder"; +import {Room} from "matrix-js-sdk/src/models/room"; +import {MatrixClientPeg} from "../../../MatrixClientPeg"; +import classNames from "classnames"; + +interface IProps { + room: Room; + onRecording: (haveRecording: boolean) => void; +} + +interface IState { + recorder?: VoiceRecorder; +} + +export default class VoiceRecordComposerTile extends React.PureComponent { + public constructor(props) { + super(props); + + this.state = { + recorder: null, // not recording by default + }; + } + + private onStartStopVoiceMessage = async () => { + // TODO: @@ TravisR: We do not want to auto-send on stop. + if (this.state.recorder) { + await this.state.recorder.stop(); + const mxc = await this.state.recorder.upload(); + MatrixClientPeg.get().sendMessage(this.props.room.roomId, { + body: "Voice message", + msgtype: "org.matrix.msc2516.voice", + url: mxc, + }); + this.setState({recorder: null}); + this.props.onRecording(false); + return; + } + const recorder = new VoiceRecorder(MatrixClientPeg.get()); + await recorder.start(); + this.props.onRecording(true); + // TODO: @@ TravisR: Run through EQ component + // recorder.frequencyData.onUpdate((freq) => { + // console.log('@@ UPDATE', freq); + // }); + this.setState({recorder}); + }; + + public render() { + const classes = classNames({ + 'mx_MessageComposer_button': !this.state.recorder, + 'mx_MessageComposer_voiceMessage': !this.state.recorder, + 'mx_VoiceRecordComposerTile_stop': !!this.state.recorder, + }); + + let tooltip = _t("Record a voice message"); + if (!!this.state.recorder) { + // TODO: @@ TravisR: Change to match behaviour + tooltip = _t("Stop & send recording"); + } + + return ( + + ); + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 07d292a0e7a..625a2e7334c 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -783,6 +783,7 @@ "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", "Change notification settings": "Change notification settings", "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.", + "Send and receive voice messages (in development)": "Send and receive voice messages (in development)", "Render LaTeX maths in messages": "Render LaTeX maths in messages", "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.", "New spinner design": "New spinner design", @@ -1635,6 +1636,8 @@ "Invited by %(sender)s": "Invited by %(sender)s", "Jump to first unread message.": "Jump to first unread message.", "Mark all as read": "Mark all as read", + "Record a voice message": "Record a voice message", + "Stop & send recording": "Stop & send recording", "Error updating main address": "Error updating main address", "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.", "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.", diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts index 4f589ba49a1..b38dee6e1a4 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.ts @@ -128,6 +128,12 @@ export const SETTINGS: {[setting: string]: ISetting} = { default: false, controller: new ReloadOnChangeController(), }, + "feature_voice_messages": { + isFeature: true, + displayName: _td("Send and receive voice messages (in development)"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_latex_maths": { isFeature: true, displayName: _td("Render LaTeX maths in messages"), diff --git a/src/voice/VoiceRecorder.ts b/src/voice/VoiceRecorder.ts new file mode 100644 index 00000000000..06c0d939fcf --- /dev/null +++ b/src/voice/VoiceRecorder.ts @@ -0,0 +1,180 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import * as Recorder from 'opus-recorder'; +import encoderPath from 'opus-recorder/dist/encoderWorker.min.js'; +import {MatrixClient} from "matrix-js-sdk/src/client"; +import CallMediaHandler from "../CallMediaHandler"; +import {SimpleObservable} from "matrix-widget-api"; + +const CHANNELS = 1; // stereo isn't important +const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality. +const BITRATE = 24000; // 24kbps is pretty high quality for our use case in opus. +const FREQ_SAMPLE_RATE = 4; // Target rate of frequency data (samples / sec). We don't need this super often. + +export interface IFrequencyPackage { + dbBars: Float32Array; + dbMin: number; + dbMax: number; + + // TODO: @@ TravisR: Generalize this for a timing package? +} + +export class VoiceRecorder { + private recorder: Recorder; + private recorderContext: AudioContext; + private recorderSource: MediaStreamAudioSourceNode; + private recorderStream: MediaStream; + private recorderFreqNode: AnalyserNode; + private buffer = new Uint8Array(0); + private mxc: string; + private recording = false; + private observable: SimpleObservable; + private freqTimerId: number; + + public constructor(private client: MatrixClient) { + } + + private async makeRecorder() { + this.recorderStream = await navigator.mediaDevices.getUserMedia({ + audio: { + // specify some audio settings so we're feeding the recorder with the + // best possible values. The browser will handle resampling for us. + sampleRate: SAMPLE_RATE, + channelCount: CHANNELS, + noiseSuppression: true, // browsers ignore constraints they can't honour + deviceId: CallMediaHandler.getAudioInput(), + }, + }); + this.recorderContext = new AudioContext({ + latencyHint: "interactive", + sampleRate: SAMPLE_RATE, // once again, the browser will resample for us + }); + this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream); + this.recorderFreqNode = this.recorderContext.createAnalyser(); + this.recorderSource.connect(this.recorderFreqNode); + this.recorder = new Recorder({ + encoderPath, // magic from webpack + encoderSampleRate: SAMPLE_RATE, + encoderApplication: 2048, // voice (default is "audio") + streamPages: true, // this speeds up the encoding process by using CPU over time + encoderFrameSize: 20, // ms, arbitrary frame size we send to the encoder + numberOfChannels: CHANNELS, + sourceNode: this.recorderSource, + encoderBitRate: BITRATE, + + // We use low values for the following to ease CPU usage - the resulting waveform + // is indistinguishable for a voice message. Note that the underlying library will + // pick defaults which prefer the highest possible quality, CPU be damned. + encoderComplexity: 3, // 0-10, 10 is slow and high quality. + resampleQuality: 3, // 0-10, 10 is slow and high quality + }); + this.recorder.ondataavailable = (a: ArrayBuffer) => { + const buf = new Uint8Array(a); + const newBuf = new Uint8Array(this.buffer.length + buf.length); + newBuf.set(this.buffer, 0); + newBuf.set(buf, this.buffer.length); + this.buffer = newBuf; + }; + } + + public get frequencyData(): SimpleObservable { + if (!this.recording) throw new Error("No observable when not recording"); + return this.observable; + } + + public get isSupported(): boolean { + return !!Recorder.isRecordingSupported(); + } + + public get hasRecording(): boolean { + return this.buffer.length > 0; + } + + public get mxcUri(): string { + if (!this.mxc) { + throw new Error("Recording has not been uploaded yet"); + } + return this.mxc; + } + + public async start(): Promise { + if (this.mxc || this.hasRecording) { + throw new Error("Recording already prepared"); + } + if (this.recording) { + throw new Error("Recording already in progress"); + } + if (this.observable) { + this.observable.close(); + } + this.observable = new SimpleObservable(); + await this.makeRecorder(); + this.freqTimerId = setInterval(() => { + if (!this.recording) return; + const data = new Float32Array(this.recorderFreqNode.frequencyBinCount); + this.recorderFreqNode.getFloatFrequencyData(data); + this.observable.update({ + dbBars: data, + dbMin: this.recorderFreqNode.minDecibels, + dbMax: this.recorderFreqNode.maxDecibels, + }); + }, 1000 / FREQ_SAMPLE_RATE) as any as number; // XXX: Linter doesn't understand timer environment + await this.recorder.start(); + this.recording = true; + } + + public async stop(): Promise { + if (!this.recording) { + throw new Error("No recording to stop"); + } + + // Disconnect the source early to start shutting down resources + this.recorderSource.disconnect(); + await this.recorder.stop(); + + // close the context after the recorder so the recorder doesn't try to + // connect anything to the context (this would generate a warning) + await this.recorderContext.close(); + + // Now stop all the media tracks so we can release them back to the user/OS + this.recorderStream.getTracks().forEach(t => t.stop()); + + // Finally do our post-processing and clean up + clearInterval(this.freqTimerId); + this.recording = false; + await this.recorder.close(); + + return this.buffer; + } + + public async upload(): Promise { + if (!this.hasRecording) { + throw new Error("No recording available to upload"); + } + + if (this.mxc) return this.mxc; + + this.mxc = await this.client.uploadContent(new Blob([this.buffer], { + type: "audio/ogg", + }), { + onlyContentUri: false, // to stop the warnings in the console + }).then(r => r['content_uri']); + return this.mxc; + } +} + +window.mxVoiceRecorder = VoiceRecorder; diff --git a/yarn.lock b/yarn.lock index 58686248f72..1763a42e750 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6096,6 +6096,11 @@ optionator@^0.9.1: type-check "^0.4.0" word-wrap "^1.2.3" +opus-recorder@^8.0.3: + version "8.0.3" + resolved "https://registry.yarnpkg.com/opus-recorder/-/opus-recorder-8.0.3.tgz#f7b44f8f68500c9b96a15042a69f915fd9c1716d" + integrity sha512-8vXGiRwlJAavT9D3yYzukNVXQ8vEcKHcsQL/zXO24DQtJ0PLXvoPHNQPJrbMCdB4ypJgWDExvHF4JitQDL7dng== + os-tmpdir@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"