feat: make event bus (emitter) type-safe (#1591)

This commit is contained in:
Phan An 2022-11-15 16:52:38 +01:00 committed by GitHub
parent 02c5e79dac
commit 5992fda776
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 125 additions and 234 deletions

View file

@ -32,6 +32,7 @@
"sketch-js": "^1.1.3",
"slugify": "^1.0.2",
"three": "^0.146.0",
"tiny-typed-emitter": "^2.1.0",
"vue": "^3.2.32",
"vue-global-events": "^2.1.1",
"youtube-player": "^3.0.4"

View file

@ -48,7 +48,7 @@ const viewAlbumDetails = () => trigger(() => router.go(`album/${album.value!.id}
const viewArtistDetails = () => trigger(() => router.go(`artist/${album.value!.artist_id}`))
const download = () => trigger(() => downloadService.fromAlbum(album.value!))
eventBus.on('ALBUM_CONTEXT_MENU_REQUESTED', async (e: MouseEvent, _album: Album) => {
eventBus.on('ALBUM_CONTEXT_MENU_REQUESTED', async (e, _album) => {
album.value = _album
await open(e.pageY, e.pageX, { album })
})

View file

@ -47,7 +47,7 @@ const shuffle = () => trigger(async () => {
const viewArtistDetails = () => trigger(() => router.go(`artist/${artist.value!.id}`))
const download = () => trigger(() => downloadService.fromArtist(artist.value!))
eventBus.on('ARTIST_CONTEXT_MENU_REQUESTED', async (e: MouseEvent, _artist: Artist) => {
eventBus.on('ARTIST_CONTEXT_MENU_REQUESTED', async (e, _artist) => {
artist.value = _artist
await open(e.pageY, e.pageX, { _artist })
})

View file

@ -46,36 +46,29 @@ watch(activeModalName, name => name ? dialog.value?.showModal() : dialog.value?.
const close = () => (activeModalName.value = null)
eventBus.on({
MODAL_SHOW_ABOUT_KOEL: () => (activeModalName.value = 'about-koel'),
MODAL_SHOW_ADD_USER_FORM: () => (activeModalName.value = 'add-user-form'),
MODAL_SHOW_CREATE_PLAYLIST_FORM: () => (activeModalName.value = 'create-playlist-form'),
MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM: () => (activeModalName.value = 'create-smart-playlist-form'),
MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM: () => (activeModalName.value = 'create-playlist-folder-form'),
MODAL_SHOW_EDIT_PLAYLIST_FORM: (playlist: Playlist) => {
eventBus.on('MODAL_SHOW_ABOUT_KOEL', () => (activeModalName.value = 'about-koel'))
.on('MODAL_SHOW_ADD_USER_FORM', () => (activeModalName.value = 'add-user-form'))
.on('MODAL_SHOW_CREATE_PLAYLIST_FORM', () => (activeModalName.value = 'create-playlist-form'))
.on('MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM', () => (activeModalName.value = 'create-smart-playlist-form'))
.on('MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM', () => (activeModalName.value = 'create-playlist-folder-form'))
.on('MODAL_SHOW_EDIT_PLAYLIST_FORM', playlist => {
playlistToEdit.value = playlist
activeModalName.value = playlist.is_smart ? 'edit-smart-playlist-form' : 'edit-playlist-form'
},
MODAL_SHOW_EDIT_USER_FORM: (user: User) => {
})
.on('MODAL_SHOW_EDIT_USER_FORM', user => {
userToEdit.value = user
activeModalName.value = 'edit-user-form'
},
MODAL_SHOW_EDIT_SONG_FORM: (songs: Song | Song[], initialTab: EditSongFormTabName = 'details') => {
})
.on('MODAL_SHOW_EDIT_SONG_FORM', (songs, initialTab: EditSongFormTabName = 'details') => {
songsToEdit.value = arrayify(songs)
editSongFormInitialTab.value = initialTab
activeModalName.value = 'edit-song-form'
},
MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM: (folder: PlaylistFolder) => {
})
.on('MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM', folder => {
playlistFolderToEdit.value = folder
activeModalName.value = 'edit-playlist-folder-form'
},
MODAL_SHOW_EQUALIZER: () => (activeModalName.value = 'equalizer')
})
})
.on('MODAL_SHOW_EQUALIZER', () => (activeModalName.value = 'equalizer'))
</script>
<style lang="scss" scoped>

View file

@ -145,13 +145,11 @@ router.onRouteChanged(route => {
activeScreen.value = route.screen
})
eventBus.on({
/**
* Listen to toggle sidebar event to show or hide the sidebar.
* This should only be triggered on a mobile device.
*/
TOGGLE_SIDEBAR: () => (mobileShowing.value = !mobileShowing.value)
})
/**
* Listen to toggle sidebar event to show or hide the sidebar.
* This should only be triggered on a mobile device.
*/
eventBus.on('TOGGLE_SIDEBAR', () => (mobileShowing.value = !mobileShowing.value))
</script>
<style lang="scss" scoped>

View file

@ -12,7 +12,6 @@
import { useContextMenu } from '@/composables'
import { eventBus } from '@/utils'
import { EventName } from '@/config'
import { onMounted } from 'vue'
const { base, ContextMenuBase, open, trigger } = useContextMenu()
@ -24,9 +23,5 @@ const actionToEventMap: Record<string, EventName> = {
const onItemClicked = (key: keyof typeof actionToEventMap) => trigger(() => eventBus.emit(actionToEventMap[key]))
onMounted(() => {
eventBus.on('CREATE_NEW_PLAYLIST_CONTEXT_MENU_REQUESTED', async (e: MouseEvent) => {
await open(e.pageY, e.pageX)
})
})
eventBus.on('CREATE_NEW_PLAYLIST_CONTEXT_MENU_REQUESTED', async e => await open(e.pageY, e.pageX))
</script>

View file

@ -8,7 +8,7 @@
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { ref } from 'vue'
import { eventBus } from '@/utils'
import { useContextMenu } from '@/composables'
@ -18,10 +18,8 @@ const playlist = ref<Playlist>()
const editPlaylist = () => trigger(() => eventBus.emit('MODAL_SHOW_EDIT_PLAYLIST_FORM', playlist.value))
const deletePlaylist = () => trigger(() => eventBus.emit('PLAYLIST_DELETE', playlist.value))
onMounted(() => {
eventBus.on('PLAYLIST_CONTEXT_MENU_REQUESTED', async (event: MouseEvent, _playlist: Playlist) => {
playlist.value = _playlist
await open(event.pageY, event.pageX, { playlist })
})
eventBus.on('PLAYLIST_CONTEXT_MENU_REQUESTED', async (event, _playlist) => {
playlist.value = _playlist
await open(event.pageY, event.pageX, { playlist })
})
</script>

View file

@ -42,7 +42,7 @@ const shuffle = () => trigger(async () => {
const rename = () => trigger(() => eventBus.emit('MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM', folder.value))
const destroy = () => trigger(() => eventBus.emit('PLAYLIST_FOLDER_DELETE', folder.value))
eventBus.on('PLAYLIST_FOLDER_CONTEXT_MENU_REQUESTED', async (e: MouseEvent, _folder: PlaylistFolder) => {
eventBus.on('PLAYLIST_FOLDER_CONTEXT_MENU_REQUESTED', async (e, _folder) => {
folder.value = _folder
await open(e.pageY, e.pageX, { folder })
})

View file

@ -75,7 +75,8 @@ const libraryEmpty = computed(() => commonStore.state.song_length === 0)
const loading = ref(false)
let initialized = false
eventBus.on(['SONGS_DELETED', 'SONGS_UPDATED'], () => overviewStore.refresh())
eventBus.on('SONGS_DELETED', () => overviewStore.refresh())
.on('SONGS_UPDATED', () => overviewStore.refresh())
useScreen('Home').onScreenActivated(async () => {
if (!initialized) {

View file

@ -135,11 +135,9 @@ watch(playlistId, async id => {
router.onRouteChanged(route => route.screen === 'Playlist' && (playlistId.value = parseInt(route.params!.id)))
eventBus.on({
PLAYLIST_UPDATED: async (updated: Playlist) => updated.id === playlistId.value && await fetchSongs(),
PLAYLIST_SONGS_REMOVED: async (playlist: Playlist, removed: Song[]) => {
eventBus.on('PLAYLIST_UPDATED', async updated => updated.id === playlistId.value && await fetchSongs())
.on('PLAYLIST_SONGS_REMOVED', async (playlist, removed) => {
if (playlist.id !== playlistId.value) return
songs.value = differenceBy(songs.value, removed, 'id')
}
})
})
</script>

View file

@ -109,7 +109,7 @@ const removeSelected = () => selectedSongs.value.length && queueStore.unqueue(se
const onPressEnter = () => selectedSongs.value.length && playbackService.play(selectedSongs.value[0])
const onReorder = (target: Song) => queueStore.move(selectedSongs.value, target)
eventBus.on('SONG_QUEUED_FROM_ROUTE', async (id: string) => {
eventBus.on('SONG_QUEUED_FROM_ROUTE', async id => {
let song: Song | undefined
try {
@ -127,7 +127,7 @@ eventBus.on('SONG_QUEUED_FROM_ROUTE', async (id: string) => {
loading.value = false
}
queueStore.queueIfNotQueued(song)
await playbackService.play(song)
queueStore.queueIfNotQueued(song!)
await playbackService.play(song!)
})
</script>

View file

@ -26,7 +26,7 @@ import { CurrentSongKey } from '@/symbols'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
let player: YouTubePlayer | null = null
let player: YouTubePlayer
const title = ref('YouTube Video')
const getPlayer = () => {
@ -50,7 +50,7 @@ const currentSong = requireInjection(CurrentSongKey)
*/
watch(() => currentSong.value?.playback_state, state => state === 'Playing' && player?.pauseVideo())
eventBus.on('PLAY_YOUTUBE_VIDEO', (payload: { id: string, title: string }) => {
eventBus.on('PLAY_YOUTUBE_VIDEO', payload => {
title.value = payload.title
use(getPlayer(), player => {

View file

@ -113,16 +113,12 @@ const doSearch = async () => {
searching.value = false
}
eventBus.on({
SEARCH_KEYWORDS_CHANGED: async (_q: string) => {
q.value = _q
eventBus.on('SEARCH_KEYWORDS_CHANGED', async _q => {
q.value = _q
await doSearch()
}).on('SONGS_DELETED', async songs => {
if (intersectionBy(songs, excerpt.value.songs, 'id').length !== 0) {
await doSearch()
},
SONGS_DELETED: async (songs: Song[]) => {
if (intersectionBy(songs, excerpt.value.songs, 'id').length !== 0) {
await doSearch()
}
}
})
</script>

View file

@ -150,7 +150,7 @@ const deleteFromFilesystem = () => trigger(async () => {
}
})
eventBus.on('SONG_CONTEXT_MENU_REQUESTED', async (e: MouseEvent, _songs: Song | Song[]) => {
eventBus.on('SONG_CONTEXT_MENU_REQUESTED', async (e, _songs) => {
songs.value = arrayify(_songs)
await open(e.pageY, e.pageX, { songs: songs.value })
})

View file

@ -88,9 +88,7 @@ const open = async (_top = 0, _left = 0) => {
eventBus.emit('CONTEXT_MENU_OPENED', el)
}
const close = () => {
shown.value = false
}
const close = () => (shown.value = false)
// ensure there's only one context menu at any time
eventBus.on('CONTEXT_MENU_OPENED', target => target === el || close())

View file

@ -35,10 +35,8 @@ const show = (options: Partial<OverlayState>) => {
const hide = () => (state.showing = false)
eventBus.on({
SHOW_OVERLAY: show,
HIDE_OVERLAY: hide
})
eventBus.on('SHOW_OVERLAY', options => show(options))
.on('HIDE_OVERLAY', () => hide())
</script>
<style lang="scss">

View file

@ -55,11 +55,9 @@ const onSubmit = () => {
const maybeGoToSearchScreen = () => isMobile.any || router.go('search')
eventBus.on({
FOCUS_SEARCH_FIELD: () => {
input.value?.focus()
input.value?.select()
}
eventBus.on('FOCUS_SEARCH_FIELD', () => {
input.value?.focus()
input.value?.select()
})
</script>

View file

@ -14,31 +14,22 @@ export const GlobalEventListeners = defineComponent({
const dialog = requireInjection(DialogBoxKey)
const router = requireInjection(RouterKey)
eventBus.on({
PLAYLIST_DELETE: async (playlist: Playlist) => {
if (await dialog.value.confirm(`Delete the playlist "${playlist.name}"?`)) {
await playlistStore.delete(playlist)
toaster.value.success(`Playlist "${playlist.name}" deleted.`)
router.go('home')
}
},
PLAYLIST_FOLDER_DELETE: async (folder: PlaylistFolder) => {
if (await dialog.value.confirm(`Delete the playlist folder "${folder.name}"?`)) {
await playlistFolderStore.delete(folder)
toaster.value.success(`Playlist folder "${folder.name}" deleted.`)
router.go('home')
}
},
/**
* Log the current user out and reset the application state.
*/
LOG_OUT: async () => {
await userStore.logout()
authService.destroy()
forceReloadWindow()
eventBus.on('PLAYLIST_DELETE', async playlist => {
if (await dialog.value.confirm(`Delete the playlist "${playlist.name}"?`)) {
await playlistStore.delete(playlist)
toaster.value.success(`Playlist "${playlist.name}" deleted.`)
router.go('home')
}
}).on('PLAYLIST_FOLDER_DELETE', async folder => {
if (await dialog.value.confirm(`Delete the playlist folder "${folder.name}"?`)) {
await playlistFolderStore.delete(folder)
toaster.value.success(`Playlist folder "${folder.name}" deleted.`)
router.go('home')
}
}).on('LOG_OUT', async () => {
await userStore.logout()
authService.destroy()
forceReloadWindow()
})
return () => slots.default?.()

View file

@ -103,9 +103,7 @@ export const useSongList = (songs: Ref<Song[]>, config: Partial<SongListConfig>
songs.value = orderBy(songs.value, sortFields, order)
}
eventBus.on('SONGS_DELETED', (deletedSongs: Song[]) => {
songs.value = differenceBy(songs.value, deletedSongs, 'id')
})
eventBus.on('SONGS_DELETED', deletedSongs => (songs.value = differenceBy(songs.value, deletedSongs, 'id')))
provideReadonly(SongsKey, songs, false)
provideReadonly(SelectedSongsKey, selectedSongs, false)

View file

@ -1,49 +1,52 @@
export type EventName =
| 'LOG_OUT'
| 'TOGGLE_SIDEBAR'
| 'SHOW_OVERLAY'
| 'HIDE_OVERLAY'
| 'FOCUS_SEARCH_FIELD'
| 'PLAY_YOUTUBE_VIDEO'
| 'INIT_EQUALIZER'
| 'SEARCH_KEYWORDS_CHANGED'
import { Ref } from 'vue'
| 'SONG_CONTEXT_MENU_REQUESTED'
| 'ALBUM_CONTEXT_MENU_REQUESTED'
| 'ARTIST_CONTEXT_MENU_REQUESTED'
| 'CREATE_NEW_PLAYLIST_CONTEXT_MENU_REQUESTED'
| 'PLAYLIST_CONTEXT_MENU_REQUESTED'
| 'PLAYLIST_FOLDER_CONTEXT_MENU_REQUESTED'
| 'CONTEXT_MENU_OPENED'
export interface Events {
LOG_OUT: () => void
TOGGLE_SIDEBAR: () => void
SHOW_OVERLAY: (options: Partial<OverlayState>) => void
HIDE_OVERLAY: () => void
FOCUS_SEARCH_FIELD: () => void
PLAY_YOUTUBE_VIDEO: (payload: { id: string, title: string }) => void
SEARCH_KEYWORDS_CHANGED: (keywords: string) => void
| 'MODAL_SHOW_ADD_USER_FORM'
| 'MODAL_SHOW_EDIT_USER_FORM'
| 'MODAL_SHOW_EDIT_SONG_FORM'
| 'MODAL_SHOW_CREATE_PLAYLIST_FORM'
| 'MODAL_SHOW_EDIT_PLAYLIST_FORM'
| 'MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM'
| 'MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM'
| 'MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM'
| 'MODAL_SHOW_ABOUT_KOEL'
| 'MODAL_SHOW_EQUALIZER'
SONG_CONTEXT_MENU_REQUESTED: (event: MouseEvent, songs: Song | Song[]) => void
ALBUM_CONTEXT_MENU_REQUESTED: (event: MouseEvent, album: Album) => void
ARTIST_CONTEXT_MENU_REQUESTED: (event: MouseEvent, artist: Artist) => void
CREATE_NEW_PLAYLIST_CONTEXT_MENU_REQUESTED: (event: MouseEvent) => void
PLAYLIST_CONTEXT_MENU_REQUESTED: (event: MouseEvent, playlist: Playlist) => void
PLAYLIST_FOLDER_CONTEXT_MENU_REQUESTED: (event: MouseEvent, playlistFolder: PlaylistFolder) => void
CONTEXT_MENU_OPENED: (el: Ref<HTMLElement> | HTMLElement) => void
| 'PLAYLIST_DELETE'
| 'PLAYLIST_FOLDER_DELETE'
| 'PLAYLIST_SONGS_REMOVED'
| 'PLAYLIST_UPDATED'
| 'SONGS_UPDATED'
| 'SONGS_DELETED'
| 'SONG_QUEUED_FROM_ROUTE'
MODAL_SHOW_ADD_USER_FORM: () => void
MODAL_SHOW_EDIT_USER_FORM: (user: User) => void
MODAL_SHOW_EDIT_SONG_FORM: (songs: Song | Song[], initialTab: EditSongFormTabName) => void
MODAL_SHOW_CREATE_PLAYLIST_FORM: () => void
MODAL_SHOW_EDIT_PLAYLIST_FORM: (playlist: Playlist) => void
MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM: () => void
MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM: () => void
MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM: (playlistFolder: PlaylistFolder) => void
MODAL_SHOW_ABOUT_KOEL: () => void
MODAL_SHOW_EQUALIZER: () => void
PLAYLIST_DELETE: (playlist: Playlist) => void
PLAYLIST_FOLDER_DELETE: (playlistFolder: PlaylistFolder) => void
PLAYLIST_SONGS_REMOVED: (playlist: Playlist, songs: Song[]) => void
PLAYLIST_UPDATED: (playlist: Playlist) => void
SONGS_UPDATED: () => void
SONGS_DELETED: (songs: Song[]) => void
SONG_QUEUED_FROM_ROUTE: (songId: string) => void
SOCKET_TOGGLE_PLAYBACK: () => void
SOCKET_TOGGLE_FAVORITE: () => void
SOCKET_PLAY_NEXT: () => void
SOCKET_PLAY_PREV: () => void
SOCKET_PLAYBACK_STOPPED: () => void
SOCKET_GET_STATUS: () => void
SOCKET_STATUS: () => void
SOCKET_GET_CURRENT_SONG: () => void
SOCKET_SONG: (song: Song) => void
SOCKET_SET_VOLUME: (volume: number) => void
SOCKET_VOLUME_CHANGED: (volume: number) => void
}
// socket events
| 'SOCKET_TOGGLE_PLAYBACK'
| 'SOCKET_TOGGLE_FAVORITE'
| 'SOCKET_PLAY_NEXT'
| 'SOCKET_PLAY_PREV'
| 'SOCKET_PLAYBACK_STOPPED'
| 'SOCKET_GET_STATUS'
| 'SOCKET_STATUS'
| 'SOCKET_GET_CURRENT_SONG'
| 'SOCKET_SONG'
| 'SOCKET_SET_VOLUME'
| 'SOCKET_VOLUME_CHANGED'

View file

@ -1,57 +0,0 @@
import UnitTestCase from '@/__tests__/UnitTestCase'
import { expect, it, vi } from 'vitest'
import { eventBus } from './eventBus'
new class extends UnitTestCase {
protected beforeEach () {
super.beforeEach(() => (eventBus.all = new Map()))
}
protected test () {
it('listens on a single event', () => {
const mock = vi.fn()
eventBus.on('SHOW_OVERLAY', mock)
eventBus.emit('SHOW_OVERLAY')
expect(mock).toHaveBeenCalledOnce()
})
it('listens with parameters', () => {
const mock = vi.fn()
eventBus.on('SHOW_OVERLAY', mock)
eventBus.emit('SHOW_OVERLAY', 'foo', 'bar')
expect(mock).toHaveBeenNthCalledWith(1, 'foo', 'bar')
})
it('registers multiple listeners at once', () => {
const mock1 = vi.fn()
const mock2 = vi.fn()
eventBus.on({
SHOW_OVERLAY: mock1,
MODAL_SHOW_ABOUT_KOEL: mock2
})
eventBus.emit('SHOW_OVERLAY')
expect(mock1).toHaveBeenCalledOnce()
eventBus.emit('MODAL_SHOW_ABOUT_KOEL')
expect(mock2).toHaveBeenCalledOnce()
})
it('queue up listeners on same event', () => {
const mock1 = vi.fn()
const mock2 = vi.fn()
eventBus.on('SHOW_OVERLAY', mock1)
eventBus.on('SHOW_OVERLAY', mock2)
eventBus.emit('SHOW_OVERLAY')
expect(mock1).toHaveBeenCalledOnce()
expect(mock2).toHaveBeenCalledOnce()
})
}
}

View file

@ -1,30 +1,7 @@
import { EventName } from '@/config'
import { logger } from '@/utils'
import { TypedEmitter } from 'tiny-typed-emitter'
import { Events } from '@/config'
export const eventBus = {
all: new Map(),
const eventBus = new TypedEmitter<Events>()
eventBus.setMaxListeners(100)
on (name: EventName | EventName[] | Partial<{ [K in EventName]: Closure }>, callback?: Closure) {
if (Array.isArray(name)) {
name.forEach(k => this.on(k, callback))
return
}
if (typeof name === 'object') {
for (const k in name) {
this.on(k as EventName, name[k as EventName])
}
return
}
this.all.has(name) ? this.all.get(name).push(callback) : this.all.set(name, [callback])
},
emit (name: EventName, ...args: any[]) {
if (this.all.has(name)) {
this.all.get(name).forEach((cb: Closure) => cb(...args))
} else {
logger.warn(`Event ${name} is not listened by any component`)
}
}
}
export { eventBus }

View file

@ -1,4 +1,3 @@
export * from './eventBus'
export * from './formatters'
export * from './supports'
export * from './common'
@ -8,3 +7,4 @@ export * from './fileReader'
export * from './directoryReader'
export * from './logger'
export * from './floatingUi'
export * from './eventBus'

View file

@ -6030,6 +6030,11 @@ through@2, through@^2.3.8, through@~2.3, through@~2.3.1:
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==
tiny-typed-emitter@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz#b3b027fdd389ff81a152c8e847ee2f5be9fad7b5"
integrity sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==
tinybench@^2.2.1:
version "2.3.0"
resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.3.0.tgz#febb2e697c735c0cdb8eb1e43cb1d2fa1821f983"