├── README.md ├── index.js ├── package.json └── public ├── css ├── filebrowser.css ├── files.svg ├── kclient.css ├── mic.svg └── speaker.svg ├── favicon.ico ├── filebrowser.html ├── icon.png ├── index.html ├── js ├── filebrowser.js ├── jquery.min.js └── kclient.js └── manifest.json /README.md: -------------------------------------------------------------------------------- 1 | # Kclient 2 | 3 | Simple Iframe wrapper for the [KasmVNC](https://github.com/kasmtech/KasmVNC) protocol to add audio and file management. 4 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // LinuxServer KasmVNC Client 2 | 3 | //// Env variables //// 4 | var CUSTOM_USER = process.env.CUSTOM_USER || 'abc'; 5 | var PASSWORD = process.env.PASSWORD || 'abc'; 6 | var SUBFOLDER = process.env.SUBFOLDER || '/'; 7 | var TITLE = process.env.TITLE || 'KasmVNC Client'; 8 | var FM_HOME = process.env.FM_HOME || '/config'; 9 | var PATH; 10 | if (SUBFOLDER != '/') { 11 | PATH = '&path=' + SUBFOLDER.substring(1) + 'websockify' 12 | } else { 13 | PATH = false; 14 | } 15 | //// Application Variables //// 16 | var socketIO = require('socket.io'); 17 | var express = require('express'); 18 | var ejs = require('ejs'); 19 | var app = require('express')(); 20 | var http = require('http').Server(app); 21 | var bodyParser = require('body-parser'); 22 | var baseRouter = express.Router(); 23 | var fsw = require('fs').promises; 24 | var fs = require('fs'); 25 | // Audio init 26 | var audioEnabled = true; 27 | var PulseAudio = require('pulseaudio2'); 28 | var pulse = new PulseAudio(); 29 | pulse.on('error', function(error) { 30 | console.log(error); 31 | audioEnabled = false; 32 | console.log('Kclient was unable to init audio, it is possible your host lacks support!!!!'); 33 | }); 34 | 35 | 36 | //// Server Paths Main //// 37 | app.engine('html', require('ejs').renderFile); 38 | app.engine('json', require('ejs').renderFile); 39 | baseRouter.use('/public', express.static(__dirname + '/public')); 40 | baseRouter.use('/vnc', express.static("/usr/share/kasmvnc/www/")); 41 | baseRouter.get('/', function (req, res) { 42 | res.render(__dirname + '/public/index.html', {title: TITLE, path: PATH}); 43 | }); 44 | baseRouter.get('/favicon.ico', function (req, res) { 45 | res.sendFile(__dirname + '/public/favicon.ico'); 46 | }); 47 | baseRouter.get('/manifest.json', function (req, res) { 48 | res.render(__dirname + '/public/manifest.json', {title: TITLE}); 49 | }); 50 | 51 | //// Web File Browser //// 52 | // Send landing page 53 | baseRouter.get('/files', function (req, res) { 54 | res.sendFile( __dirname + '/public/filebrowser.html'); 55 | }); 56 | // Websocket comms // 57 | io = socketIO(http, {path: SUBFOLDER + 'files/socket.io',maxHttpBufferSize: 200000000}); 58 | io.on('connection', async function (socket) { 59 | let id = socket.id; 60 | 61 | //// Functions //// 62 | 63 | // Open default location 64 | async function checkAuth(password) { 65 | getFiles(FM_HOME); 66 | } 67 | 68 | // Emit to user 69 | function send(command, data) { 70 | io.sockets.to(id).emit(command, data); 71 | } 72 | 73 | // Get file list for directory 74 | async function getFiles(directory) { 75 | try { 76 | let items = await fsw.readdir(directory); 77 | if (items.length > 0) { 78 | let dirs = []; 79 | let files = []; 80 | for await (let item of items) { 81 | let fullPath = directory + '/' + item; 82 | if (fs.lstatSync(fullPath).isDirectory()) { 83 | dirs.push(item); 84 | } else { 85 | files.push(item); 86 | } 87 | } 88 | send('renderfiles', [dirs, files, directory]); 89 | } else { 90 | send('renderfiles', [[], [], directory]); 91 | } 92 | } catch (error) { 93 | send('renderfiles', [[], [], directory]); 94 | } 95 | } 96 | 97 | // Send file to client 98 | async function downloadFile(file) { 99 | let fileName = file.split('/').slice(-1)[0]; 100 | let data = await fsw.readFile(file); 101 | send('sendfile', [data, fileName]); 102 | } 103 | 104 | // Write client sent file 105 | async function uploadFile(res) { 106 | let directory = res[0]; 107 | let filePath = res[1]; 108 | let data = res[2]; 109 | let render = res[3]; 110 | let dirArr = filePath.split('/'); 111 | let folder = filePath.replace(dirArr[dirArr.length - 1], '') 112 | await fsw.mkdir(folder, { recursive: true }); 113 | await fsw.writeFile(filePath, Buffer.from(data)); 114 | if (render) { 115 | getFiles(directory); 116 | } 117 | } 118 | 119 | // Delete files 120 | async function deleteFiles(res) { 121 | let item = res[0]; 122 | let directory = res[1]; 123 | item = item.replace("|","'"); 124 | if (fs.lstatSync(item).isDirectory()) { 125 | await fsw.rm(item, {recursive: true}); 126 | } else { 127 | await fsw.unlink(item); 128 | } 129 | getFiles(directory); 130 | } 131 | 132 | // Create a folder 133 | async function createFolder(res) { 134 | let dir = res[0]; 135 | let directory = res[1]; 136 | if (!fs.existsSync(dir)){ 137 | await fsw.mkdir(dir); 138 | } 139 | getFiles(directory); 140 | } 141 | 142 | // Incoming socket requests 143 | socket.on('open', checkAuth); 144 | socket.on('getfiles', getFiles); 145 | socket.on('downloadfile', downloadFile); 146 | socket.on('uploadfile', uploadFile); 147 | socket.on('deletefiles', deleteFiles); 148 | socket.on('createfolder', createFolder); 149 | }); 150 | 151 | //// PCM Audio Wrapper //// 152 | aio = socketIO(http, {path: SUBFOLDER + 'audio/socket.io'}); 153 | aio.on('connection', function (socket) { 154 | var record; 155 | let id = socket.id; 156 | 157 | function open() { 158 | if (audioEnabled) { 159 | if (record) record.end(); 160 | record = pulse.createRecordStream({ 161 | device: 'auto_null.monitor', 162 | channels: 2, 163 | rate: 44100, 164 | format: 'S16LE', 165 | }); 166 | record.on('connection', function(){ 167 | record.on('data', function(chunk) { 168 | // Only send non-zero audio data 169 | let i16Array = Int16Array.from(chunk); 170 | if (! i16Array.every(item => item === 0)) { 171 | aio.sockets.to(id).emit('audio', chunk); 172 | } 173 | }); 174 | }); 175 | } 176 | } 177 | function close() { 178 | if (audioEnabled) { 179 | if (record) record.end(); 180 | } 181 | } 182 | 183 | // Dump blobs to pulseaudio sink 184 | async function micData(buffer) { 185 | await fsw.writeFile('/defaults/mic.sock', buffer); 186 | } 187 | 188 | // Incoming socket requests 189 | socket.on('open', open); 190 | socket.on('close', close); 191 | socket.on('disconnect', close); 192 | socket.on('micdata', micData); 193 | }); 194 | 195 | // Spin up application on 6900 196 | app.use(SUBFOLDER, baseRouter); 197 | http.listen(6900); 198 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kclient", 3 | "version": "0.4.1", 4 | "description": "Kclient is a wrapper for KasmVNC to add functionality to a containerized environment", 5 | "main": "index.js", 6 | "dependencies": { 7 | "ejs": "^3.1.8", 8 | "express": "^4.18.2", 9 | "pulseaudio2": "^0.5.5", 10 | "socket.io": "^4.6.0" 11 | }, 12 | "devDependencies": {}, 13 | "scripts": { 14 | "test": "echo \"Error: no test specified\" && exit 1" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/linuxserver/kclient.git" 19 | }, 20 | "keywords": [ 21 | "VNC", 22 | "Webtop", 23 | "VDI", 24 | "Docker" 25 | ], 26 | "author": "thelamer", 27 | "license": "GPL-3.0-or-later", 28 | "bugs": { 29 | "url": "https://github.com/linuxserver/kclient/issues" 30 | }, 31 | "homepage": "https://github.com/linuxserver/kclient#readme" 32 | } 33 | -------------------------------------------------------------------------------- /public/css/filebrowser.css: -------------------------------------------------------------------------------- 1 | html * { 2 | font-family: Poppins,Helvetica !important; 3 | color: white !important; 4 | } 5 | 6 | .hidden { 7 | display: none; 8 | } 9 | 10 | .right { 11 | float: right; 12 | margin-right: 5px; 13 | } 14 | 15 | .directory, .file { 16 | cursor: pointer; 17 | } 18 | 19 | button { 20 | background-color: rgb(9 2 2 / 0.6); 21 | border-radius: 5px; 22 | border-style: inset; 23 | border-color: rgb(255 255 255 / 0.6); 24 | cursor: pointer; 25 | margin: 5px; 26 | } 27 | 28 | .deleteButton { 29 | margin: 0px !important; 30 | float: right; 31 | } 32 | 33 | .fileTable { 34 | border-collapse: collapse; 35 | width: 100%; 36 | margin-top: 10px; 37 | } 38 | 39 | td, th { 40 | border: 2px solid #ddd; 41 | padding: 8px; 42 | } 43 | 44 | tr:hover, button:hover { 45 | background: rgba(255, 255, 255, 0.3) 46 | } 47 | 48 | #dropzone { 49 | position: fixed; top: 0; left: 0; 50 | z-index: 9999999999; 51 | width: 100%; height: 100%; 52 | background-color: rgba(0,0,0,0.5); 53 | transition: visibility 175ms, opacity 175ms; 54 | } 55 | 56 | #loading { 57 | display: inline-block; 58 | width: 50px; 59 | height: 50px; 60 | border: 3px solid rgba(0,0,0,.3); 61 | border-radius: 50%; 62 | border-top-color: black; 63 | animation: spin 1s ease-in-out infinite; 64 | -webkit-animation: spin 1s ease-in-out infinite; 65 | } 66 | 67 | @keyframes spin { 68 | to { -webkit-transform: rotate(360deg); } 69 | } 70 | @-webkit-keyframes spin { 71 | to { -webkit-transform: rotate(360deg); } 72 | } 73 | -------------------------------------------------------------------------------- /public/css/files.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/css/kclient.css: -------------------------------------------------------------------------------- 1 | .vnc { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | bottom: 0; 6 | right: 0; 7 | width: 100%; 8 | height: 100%; 9 | border: none; 10 | margin: 0; 11 | padding: 0; 12 | overflow: hidden; 13 | } 14 | 15 | #files { 16 | display: none; 17 | position: absolute; 18 | left: 20vw; 19 | top: 50%; 20 | transform: translateY(-50%); 21 | width: 60vw; 22 | height: 60vh; 23 | z-index: 2; 24 | background-color: rgb(9 2 2 / 0.6); 25 | border-radius: 10px; 26 | border-style: inset; 27 | border-color: rgb(255 255 255 / 0.6); 28 | } 29 | 30 | #files_frame { 31 | width: 100%; 32 | height: 100%; 33 | } 34 | 35 | .close { 36 | position: absolute; 37 | background: DimGray; 38 | top: -10px; 39 | right: -10px; 40 | cursor: pointer; 41 | border-radius:50%; 42 | border-style: inset; 43 | border-color: rgb(255 255 255 / 0.6); 44 | width: 20px; 45 | height: 20px; 46 | } 47 | 48 | #lsbar { 49 | position: absolute; 50 | top: 0; 51 | left: 0; 52 | right: 0; 53 | margin-left: auto; 54 | margin-right: auto; 55 | width: max-content; 56 | display: none; 57 | background-color: rgb(9 2 2 / 0.6); 58 | border-radius: 0 0 10px 10px; 59 | border-style: inset; 60 | border-color: rgb(255 255 255 / 0.6); 61 | } 62 | 63 | .icons { 64 | margin: 5px; 65 | padding: 4px; 66 | height: 4vh; 67 | cursor: pointer; 68 | border-radius: 3px; 69 | filter: invert(100%) sepia(0%) saturate(0%) hue-rotate(82deg) brightness(105%) contrast(105%); 70 | } 71 | 72 | .icons-selected { 73 | background: rgba(0, 0, 0, 0.3); 74 | } 75 | 76 | .icons:hover { 77 | background: rgba(0, 0, 0, 0.3); 78 | } 79 | -------------------------------------------------------------------------------- /public/css/mic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/css/speaker.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linuxserver/kclient/5d4256fd27aad62fa02767e50c7c2b0bda309f87/public/favicon.ico -------------------------------------------------------------------------------- /public/filebrowser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linuxserver/kclient/5d4256fd27aad62fa02767e50c7c2b0bda309f87/public/icon.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%- title -%> 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 |
23 | 24 |
25 | 26 |
27 |
28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /public/js/filebrowser.js: -------------------------------------------------------------------------------- 1 | var host = window.location.hostname; 2 | var port = window.location.port; 3 | var protocol = window.location.protocol; 4 | var path = window.location.pathname; 5 | var socket = io(protocol + '//' + host + ':' + port, { path: path + '/socket.io'}); 6 | 7 | // Open default folder on connect 8 | socket.on('connect',function(){ 9 | $('#filebrowser').empty(); 10 | $('#filebrowser').append($('
').attr('id','loading')); 11 | socket.emit('open', ''); 12 | }); 13 | 14 | // Get file list 15 | function getFiles(directory) { 16 | directory = directory.replace("//","/"); 17 | directory = directory.replace("|","'"); 18 | let directoryClean = directory.replace("'","|"); 19 | if ((directory !== '/') && (directory.endsWith('/'))) { 20 | directory = directory.slice(0, -1); 21 | } 22 | $('#filebrowser').empty(); 23 | $('#filebrowser').append($('
').attr('id','loading')); 24 | socket.emit('getfiles', directory); 25 | } 26 | 27 | // Render file list 28 | async function renderFiles(data) { 29 | let dirs = data[0]; 30 | let files = data[1]; 31 | let directory = data[2]; 32 | let baseName = directory.split('/').slice(-1)[0]; 33 | let parentFolder = directory.replace(baseName,''); 34 | let parentLink = $('').addClass('directory').attr('onclick', 'getFiles(\'' + parentFolder + '\');').text('..'); 35 | let directoryClean = directory.replace("'","|"); 36 | if (directoryClean == '/') { 37 | directoryClean = ''; 38 | } 39 | let table = $('').addClass('fileTable'); 40 | let tableHeader = $(''); 41 | for await (name of ['Name', 'Type', 'Delete (NO WARNING)']) { 42 | tableHeader.append($(''); 45 | for await (item of [parentLink, $(''); 56 | let dirClean = dir.replace("'","|"); 57 | let link = $(''); 69 | let fileClean = file.replace("'","|"); 70 | let link = $('
').text(name)); 43 | } 44 | let parentRow = $('
').text('Parent'), $('')]) { 46 | parentRow.append(item); 47 | } 48 | table.append(tableHeader,parentRow); 49 | $('#filebrowser').empty(); 50 | $('#filebrowser').data('directory', directory); 51 | $('#filebrowser').append($('
').text(directory)); 52 | $('#filebrowser').append(table); 53 | if (dirs.length > 0) { 54 | for await (let dir of dirs) { 55 | let tableRow = $('
').addClass('directory').attr('onclick', 'getFiles(\'' + directoryClean + '/' + dirClean + '\');').text(dir); 58 | let type = $('').text('Dir'); 59 | let del = $('').append($('
').addClass('file').attr('onclick', 'downloadFile(\'' + directoryClean + '/' + fileClean + '\');').text(file); 71 | let type = $('').text('File'); 72 | let del = $('').append($('