Commit 7bad9baa authored by johni0702's avatar johni0702
Browse files

Initial commit

parents
dist
# Created by https://www.gitignore.io/api/node
### Node ###
# Logs
logs
*.log
npm-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules
jspm_packages
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# mumble-web
mumble-web is an HTML5 [Mumble] client for use in modern browsers.
A live demo is running [here](https://voice.johni0702.de/?address=voice.johni0702.de&port=443/demo).
The Mumble protocol uses TCP for control and UDP for voice.
Running in a browser, both are unavailable to this client.
Instead Websockets are used for all communications.
libopus and libsamplerate, compiled to JS via emscripten, are used for audio decoding.
Therefore, at the moment only the Opus codec is supported.
Quite a few features, most noticeably voice activity detection and all
administrative functionallity, are still missing.
### Installing
#### Download
mumble-web can either be installed directly from npm with `npm install -g mumble-web`
or from git:
```
git clone https://github.com/johni0702/mumble-web
cd mumble-web
npm install
npm run build
```
The npm version is prebuilt and ready to use whereas the git version allows you
to e.g. customize the theme before building it.
Either way you will end up with a `dist` folder that contains the static page.
#### Setup
At the time of writing this there do not seem to be any Mumble servers
which natively support Websockets. To use this client with any standard mumble
server, websockify must be set up (preferably on the same machine that the
Mumble server is running on).
You can install websockify via `npm install -g websockify` or via your package
manager `apt install websockify`.
There are two basic ways you can use websockify with mumble-web:
- Standalone, use websockify for both, websockets and serving static files
- Proxied, let your favorite web server serve static files and proxy websocket connections to websockify
##### Standalone
This is the simplest but at the same time least flexible configuration.
```
websockify --cert=mycert.crt --key=mykey.key --ssl-only --ssl-target --web=path/to/dist 443 mumbleserver:64738
```
##### Proxied
This configuration allows you to run websockify on a machine that already has
another webserver running.
```
websockify --ssl-target 64737 mumbleserver:64738
```
A sample configuration for nginx that allows access to mumble-web at
`https://voice.example.com/` and connecting at `wss://voice.example.com/demo`
(similar to the demo server) looks like this:
```
server {
listen 443 ssl;
server_name voice.example.com;
ssl_certificate /etc/letsencrypt/live/voice.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/voice.example.com/privkey.pem;
location / {
root /path/to/dist;
}
location /mumble {
proxy_pass http://websockify:64737;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
```
### License
ISC
[Mumble]: https://wiki.mumble.info/wiki/Main_Page
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="#require('./mstile-150x150.png')"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>
{
"name": "Mumble",
"icons": [
{
"src": "#require('./android-chrome-192x192.png')",
"sizes": "192x192",
"type": "image\/png"
},
{
"src": "#require('./android-chrome-512x512.png')",
"sizes": "512x512",
"type": "image\/png"
}
],
"theme_color": "#ffffff",
"display": "standalone"
}
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M3245 6924 c-230 -22 -403 -51 -577 -94 -1229 -307 -2191 -1269
-2498 -2498 -30 -123 -60 -282 -82 -442 -17 -128 -17 -652 0 -780 76 -553 226
-984 498 -1422 510 -824 1365 -1403 2314 -1567 235 -41 302 -46 600 -46 298 0
365 5 600 46 949 164 1804 743 2314 1567 272 438 422 869 498 1422 17 128 17
652 0 780 -88 645 -296 1171 -654 1652 -564 758 -1367 1229 -2323 1364 -105
14 -594 27 -690 18z m559 -75 c553 -44 1107 -241 1585 -566 185 -126 309 -229
487 -407 281 -282 462 -531 638 -882 255 -509 369 -1057 344 -1657 -29 -692
-275 -1352 -719 -1923 -106 -137 -392 -426 -526 -532 -515 -406 -1066 -639
-1718 -724 -134 -17 -656 -17 -790 0 -652 85 -1203 318 -1718 724 -134 106
-420 395 -526 532 -444 571 -690 1231 -719 1923 -21 502 54 959 229 1400 122
308 301 614 511 877 105 130 374 399 504 504 524 420 1161 678 1810 732 224
18 368 18 608 -1z"/>
<path d="M4199 6540 c-103 -18 -191 -76 -229 -151 -39 -75 -42 -136 -39 -874
2 -701 2 -711 -19 -776 -39 -118 -103 -189 -210 -230 -80 -31 -265 -38 -347
-14 -177 51 -255 131 -288 295 -13 64 -15 180 -10 810 6 803 9 755 -49 820
-76 84 -161 110 -351 110 -167 0 -235 -10 -366 -52 -350 -112 -616 -367 -711
-683 -51 -169 -60 -312 -60 -992 l0 -542 -83 -39 c-252 -118 -482 -353 -631
-645 -228 -447 -268 -1030 -105 -1522 137 -410 404 -754 704 -905 135 -68 229
-91 375 -91 184 0 257 34 277 129 9 40 11 3221 3 3822 -5 385 -1 446 41 607
25 93 77 196 131 258 49 56 145 104 222 112 l58 6 -3 -634 c-4 -721 -7 -697
78 -868 113 -229 305 -340 643 -371 188 -18 526 -8 645 18 173 39 278 88 376
179 96 89 157 199 195 353 18 70 19 115 15 699 l-3 624 69 -5 c76 -6 157 -37
210 -82 68 -58 129 -179 158 -315 35 -167 37 -312 30 -2171 -3 -1004 -7 -1897
-8 -1985 -1 -186 11 -246 67 -311 l34 -40 -63 -47 c-35 -25 -104 -70 -152 -99
l-88 -53 -317 -3 -317 -3 -12 23 c-35 69 -138 141 -264 184 -74 25 -93 27
-245 28 -183 1 -245 -10 -356 -64 -296 -144 -264 -436 62 -551 74 -26 210 -49
290 -49 89 0 220 25 301 58 80 32 100 45 159 102 l43 41 313 -1 c337 0 378 4
476 54 122 62 480 320 502 361 9 17 35 30 99 50 434 134 750 509 885 1050 78
314 85 646 21 970 -35 173 -75 289 -160 460 -147 296 -337 496 -596 630 -58
30 -112 61 -119 70 -17 20 -17 43 -1 460 15 397 10 685 -14 825 -43 239 -118
414 -244 568 -110 134 -428 303 -691 367 -112 27 -261 38 -331 25z"/>
</g>
</svg>
<!DOCTYPE html>
<html>
<head>
<!-- Favicon as generated by realfavicongenerator.net (slightly modified for webpack) -->
<link rel="apple-touch-icon" sizes="180x180" href="favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" href="favicon/favicon-32x32.png" sizes="32x32">
<link rel="icon" type="image/png" href="favicon/favicon-16x16.png" sizes="16x16">
<link rel="manifest" href="favicon/manifest.json">
<link rel="mask-icon" href="favicon/safari-pinned-tab.svg" color="#5bbad5">
<link rel="shortcut icon" href="favicon/favicon.ico">
<meta name="apple-mobile-web-app-title" content="Mumble">
<meta name="application-name" content="Mumble">
<meta name="msapplication-config" content="${require('./favicon/browserconfig.xml')}">
<meta name="theme-color" content="#ffffff">
<link rel="stylesheet" type="text/css" href="/loading.css">
</head>
<body>
<div class="loading-container" data-bind="css: { loaded: true }">
<div class="loading-circle" data-bind="css: { loaded: true }"></div>
</div>
<div id="container" style="display: none" data-bind="visible: true">
<!-- ko with: connectDialog -->
<div class="connect-dialog" data-bind="visible: visible">
<div class="dialog-header">
Connect to Server
</div>
<form data-bind="submit: connect">
<table>
<tr>
<td>Address</td>
<td><input id="address" type="text" data-bind="value: address"></td>
</tr>
<tr>
<td>Port</td>
<td><input id="port" type="text" data-bind="value: port"></td>
</tr>
<tr>
<td>Token</td>
<td><input id="token" type="text" data-bind="value: token"></td>
</tr>
<tr>
<td>Username</td>
<td><input id="username" type="text" data-bind="value: username"></td>
</tr>
</table>
<div class="dialog-footer">
<input class="dialog-close" type="button" data-bind="click: hide" value="Cancel">
<input class="dialog-submit" type="submit" value="Connect">
</div>
</form>
</div>
<!-- /ko -->
<script type="text/html" id="user-tag">
<span class="user-tag" data-bind="text: name"></span>
</script>
<script type="text/html" id="channel-tag">
<span class="channel-tag" data-bind="text: name"></span>
</script>
<div class="toolbar">
<img class="handle-horizontal" src="/svg/handle_horizontal.svg">
<img class="handle-vertical" src="/svg/handle_horizontal.svg">
<img class="tb-connect" data-bind="click: connectDialog.show"
rel="connect" src="/svg/applications-internet.svg">
<img class="tb-information" data-bind="click: connectionInfo.show"
rel="information" src="/svg/information_icon.svg">
<div class="divider"></div>
<img class="tb-mute" data-bind="visible: !thisUser() || !thisUser().selfMute(),
click: function () { requestMute(thisUser) }"
rel="mute" src="/svg/audio-input-microphone.svg">
<img class="tb-unmute tb-active" data-bind="visible: thisUser() && thisUser().selfMute(),
click: function () { requestUnmute(thisUser) }"
rel="unmute" src="/svg/audio-input-microphone-muted.svg">
<img class="tb-deaf" data-bind="visible: !thisUser() || !thisUser().selfDeaf(),
click: function () { requestDeaf(thisUser) }"
rel="deaf" src="/svg/audio-output.svg">
<img class="tb-undeaf tb-active" data-bind="visible: thisUser() && thisUser().selfDeaf(),
click: function () { requestUndeaf(thisUser) }"
rel="undeaf" src="/svg/audio-output-deafened.svg">
<img class="tb-record" data-bind="click: function(){}"
rel="record" src="/svg/media-record.svg">
<div class="divider"></div>
<img class="tb-comment" data-bind="click: commentDialog.show"
rel="comment" src="/svg/toolbar-comment.svg">
<div class="divider"></div>
<img class="tb-settings" data-bind="click: settingsDialog.show"
rel="settings" src="/svg/config_basic.svg">
<div class="divider"></div>
<img class="tb-sourcecode" data-bind="click: openSourceCode"
rel="Source Code" src="/svg/source-code.svg">
</div>
<div class="chat">
<script type="text/html" id="log-generic">
<span data-bind="text: value"></span>
</script>
<script type="text/html" id="log-welcome-message">
Welcome message: <span data-bind="html: message"></span>
</script>
<script type="text/html" id="log-chat-message">
<span data-bind="visible: channel">
(Channel)
</span>
<span data-bind="template: { name: 'user-tag', data: user }"></span>:
<span class="message-content" data-bind="html: message"></span>
</script>
<script type="text/html" id="log-chat-message-self">
To
<span data-bind="template: { if: $data.channel, name: 'channel-tag', data: $data.channel }">
</span><span data-bind="template: { if: $data.user, name: 'user-tag', data: $data.user }">
</span>:
<span class="message-content" data-bind="html: message"></span>
</script>
<script type="text/html" id="log-disconnect">
</script>
<div class="log" data-bind="foreach: {
data: log,
afterRender: function (e) {
[].forEach.call(e[1].getElementsByTagName('a'), function(e){e.target = '_blank'})
}
}">
<div class="log-entry">
<span class="log-timestamp" data-bind="text: $root.getTimeString()"></span>
<!-- ko template: { data: $data, name: function(l) { return 'log-' + l.type; } } -->
<!-- /ko -->
</div>
</div>
<form data-bind="submit: submitMessageBox">
<input id="message-box" type="text" data-bind="
attr: { placeholder: messageBoxHint }, textInput: messageBox">
</form>
</div>
<script type="text/html" id="channel">
<div class="channel" data-bind="
click: $root.select,
event: {
dblclick: $root.requestMove.bind($root, $root.thisUser())
},
css: {
selected: $root.selected() === $data,
currentChannel: users.indexOf($root.thisUser()) !== -1
}">
<div class="channel-status">
<img class="channel-description" data-bind="visible: description"
alt="description" src="/svg/comment.svg">
</div>
<div data-bind="if: description">
<div class="channel-description tooltip" data-bind="html: description"></div>
</div>
<img class="channel-icon" src="/svg/channel.svg"
data-bind="visible: !linked() && $root.thisUser().channel() !== $data">
<img class="channel-icon-active" src="/svg/channel_active.svg"
data-bind="visible: $root.thisUser().channel() === $data">
<img class="channel-icon-linked" src="/svg/channel_linked.svg"
data-bind="visible: linked() && $root.thisUser().channel() !== $data">
<div class="channel-name" data-bind="text: name"></div>
</div>
<!-- ko if: expanded -->
<!-- ko foreach: users -->
<div class="user-wrapper">
<div class="user-tree"></div>
<div class="user" data-bind="
click: $root.select,
css: {
thisClient: $root.thisUser() === $data,
selected: $root.selected() === $data
}">
<div class="user-status" data-bind="attr: { title: state }">
<img class="user-comment" data-bind="visible: comment"
alt="comment" src="/svg/comment.svg">
<img class="user-server-mute" data-bind="visible: mute"
alt="server mute" src="/svg/muted_server.svg">
<img class="user-suppress-mute" data-bind="visible: suppress"
alt="suppressed" src="/svg/muted_suppressed.svg">
<img class="user-self-mute" data-bind="visible: selfMute"
alt="self mute" src="/svg/muted_self.svg">
<img class="user-server-deaf" data-bind="visible: deaf"
alt="server deaf" src="/svg/deafened_server.svg">
<img class="user-self-deaf" data-bind="visible: selfDeaf"
alt="self deaf" src="/svg/deafened_self.svg">
<img class="user-authenticated" data-bind="visible: uid"
alt="authenticated" src="/svg/authenticated.svg">
</div>
<div data-bind="if: comment">
<div class="user-comment tooltip" data-bind="html: comment"></div>
</div>
<img class="user-talk-off" data-bind="visible: talking() == 'off'"
alt="talk off" src="/svg/talking_off.svg">
<img class="user-talk-on" data-bind="visible: talking() == 'on'"
alt="talk on" src="/svg/talking_on.svg">
<img class="user-talk-whisper" data-bind="visible: talking() == 'whisper'"
alt="whisper" src="/svg/talking_whisper.svg">
<img class="user-talk-shout" data-bind="visible: talking() == 'shout'"
alt="shout" src="/svg/talking_alt.svg">
<div class="user-name" data-bind="text: name"></div>
</div>
</div>
<!-- /ko -->
<!-- ko foreach: channels -->
<div class="channel-wrapper">
<!-- ko ifnot: users().length || channels().length -->
<div class="channel-tree"></div>
<!-- /ko -->
<div class="branch" data-bind="if: users().length || channels().length">
<img class="branch-open" src="/svg/branch_open.svg"
data-bind="click: expanded.bind($data, false), visible: expanded()">
<img class="branch-closed" src="/svg/branch_closed.svg"
data-bind="click: expanded.bind($data, true), visible: !expanded()">
</div>
<div class="channel-sub" data-bind="template: {name: 'channel', data: $data}"></div>
</div>
<!-- /ko -->
<!-- /ko -->
</script>
<div class="channel-root-container" data-bind="if: root">
<div class="channel-root" data-bind="template: {name: 'channel', data: root}"></div>
</div>
</div>
</body>
<link rel="stylesheet" type="text/css" href="/main.css">
<script src="index.js"></script>
</html>
import url from 'url'
import mumbleConnect from 'mumble-client-websocket'
import CodecsBrowser from 'mumble-client-codecs-browser'
import BufferQueueNode from 'web-audio-buffer-queue'
import MicrophoneStream from 'microphone-stream'
import audioContext from 'audio-context'
import chunker from 'stream-chunker'
import Resampler from 'libsamplerate.js'
import getUserMedia from 'getusermedia'
import ko from 'knockout'
import _dompurify from 'dompurify'
const dompurify = _dompurify(window)
function sanitize (html) {
return dompurify.sanitize(html, {
ALLOWED_TAGS: ['br', 'b', 'i', 'u', 'a', 'span', 'p']
})
}
// GUI
function ConnectDialog () {
var self = this
self.address = ko.observable('')
self.port = ko.observable('443')
self.token = ko.observable('')
self.username = ko.observable('')
self.visible = ko.observable(true)
self.show = self.visible.bind(self.visible, true)
self.hide = self.visible.bind(self.visible, false)
self.connect = function () {
self.hide()
ui.connect(self.username(), self.address(), self.port(), self.token())
}
}
function ConnectionInfo () {
var self = this
self.visible = ko.observable(false)
self.show = function () {
self.visible(true)
}
}
function CommentDialog () {
var self = this
self.visible = ko.observable(false)
self.show = function () {
self.visible(true)
}
}
function SettingsDialog () {
var self = this
self.visible = ko.observable(false)
self.show = function () {
self.visible(true)
}
}
class GlobalBindings {
constructor () {
this.client = null
this.connectDialog = new ConnectDialog()
this.connectionInfo = new ConnectionInfo()
this.commentDialog = new CommentDialog()
this.settingsDialog = new SettingsDialog()
this.log = ko.observableArray()
this.thisUser = ko.observable()
this.root = ko.observable()
this.messageBox = ko.observable('')
this.selected = ko.observable()
this.select = element => {
this.selected(element)
}
this.getTimeString = () => {
return '[' + new Date().toLocaleTimeString('en-US') + ']'
}
this.connect = (username, host, port, token) => {
this.resetClient()
log('Connecting to server ', host)
// TODO: token
mumbleConnect(`wss://${host}:${port}`, {
username: username,
codecs: CodecsBrowser
}).done(client => {
log('Connected!')
this.client = client
// Prepare for connection errors
client.on('error', function (err) {
log('Connection error:', err)
this.resetClient()
})
// Register all channels, recursively
const registerChannel = channel => {
this._newChannel(channel)
channel.children.forEach(registerChannel)
}
registerChannel(client.root)
// Register all users
client.users.forEach(user => this._newUser(user))
// Register future channels
client.on('newChannel', channel => this._newChannel(channel))
// Register future users
client.on('newUser', user => this._newUser(user))
// Handle messages
client.on('message', (sender, message, users, channels, trees) => {
ui.log.push({
type: 'chat-message',
user: sender.__ui,
channel: channels.length > 0,
message: sanitize(message)
})
})
// Set own user and root channel
this.thisUser(client.self.__ui)
this.root(client.root.__ui)
// Upate linked channels
this._updateLinks()
// Log welcome message
if (client.welcomeMessage) {
this.log.push({
type: 'welcome-message',
message: sanitize(client.welcomeMessage)
})
}
}, err => {
log('Connection error:', err)
})
}
this._newUser = user => {
const simpleProperties = {
uniqueId: 'uid',
username: 'name',
mute: 'mute',
deaf: 'deaf',
suppress: 'suppress',
selfMute: 'selfMute',