CloudTk
is based on
WebSockit2me, a
TCP to
WebSocket gateway that uses
noVNC
to display
Tk applications in a modern Web Browser. It runs on Linux and requires an Xvnc(TigerVNC) server to be loaded. Tk applications are listed on a web page.
TclHttpd dynamically launches an Xdisplay via Xvnc and then starts a matchbox window manager and launches the Tk application.
It runs on Linux x86_64 or arm(Raspberry Pi) with Tcl/Tk version 8.6.
A Starkit is available at the
CloudTk website
. Some Tk Applications listed from this wiki are
demonstrated here
.
Below are the two main files in the custom directory of TclHttpd. CloudTk.tcl controls the websocket to TCP gateway. Xdisplay.tcl uses "Standalone bgexec" [
1] to launch an Xdisplay via Xvnc, the matchbox window manager [
2] and the Tk application.
2017-12-16: I have made some changes to the Xdisplay_Reap procedure. Now it works more reliably with inline frame or <iframe> HTML tag. To see an example of CloudTk with iframe go to [
3]
Jeff Smith 2018-01-05 : Updated the version of noVNC used with CloudTk. Now web pages with iframes get the keyboard focus. Also older versions of Tcl/Tk and other non Tcl/Tk applications like Tkinter and X11 apps (e.g. xclock) run also. To see an example of other gui apps working with CloudTk go to [
4]
CloudTk.tcl
# Copyright (c) 2017 Jeff Smith
#
# See the file "license.terms" of TclHttpd for information on usage and redistribution
# of this file, and for a DISCLAIMER OF ALL WARRANTIES.
#
# I made a few modifications to the Websocket library to make it work with TclHttpd.
#
# 1. In the procedure ::websocket::takeover changed the following line from
# fconfigure $sock -translation binary -blocking on
# to
# fconfigure $sock -translation binary -blocking off
#
#
# 2. In the procedure ::websocket::Receiver changed the following line from
# binary scan $dta Iu mask
# to
# binary scan $dta I mask
#
# Without this change the intial handshake with the VNC or Telnet Server
# was intermittent ie. did not connect.
#
# So make the above modifications and then save the following to
# WebSocketTCP-gateway.tcl and drop in the custom directory.
#
# Setup the AuthUserFile and copy the default webmaster credentials to the file
# outside the Starkit.
if {![file exists $Config(AuthUserFile)]} {
set fd [open $Config(AuthUserFile) w]
puts $fd "webmaster:$authdefault(user,webmaster)"
close $fd
unset fd
}
# If the user is Upgrading noVNC by creating a noVNC directory outside the Starkit,
# remap this new directory via Doc_AddRoot.
#
# The Config(starkitTop) array variable is defined in the main.tcl file of the
# Starkit and is used by the startup scripts of TclHttpd to define certain paths.
if {[file isdirectory [file join [file dirname $Config(starkitTop)] noVNC]]} {
Doc_AddRoot /kanaka/noVNC [file join [file dirname $Config(starkitTop)] noVNC]
} else {
Doc_AddRoot /kanaka/noVNC [file join $Config(starkitTop) noVNC-master]
}
Mtype_Add .svg image/svg+xml
Url_AccessInstallPrepend ::cloudtk::AccessHook
Url_PrefixInstall /cloudtk [list ::cloudtk::Start /cloudtk]
package require websocket
namespace eval ::cloudtk {
# ensure ::cloudtk namespace exists
set ::Config(cloudtkVersion) 0.1.4
}
proc ::cloudtk::Start {prefix sock suffix} {
upvar #0 Httpd$sock data
variable Target
set suffix [Url_PathCheck [string trimleft $suffix /]]
if {![regexp {.*(/)$} $suffix _ slash]} {
set slash ""
}
if {[info exists ::Session:$suffix]} {
upvar #0 Session:$suffix state
if { $state(type) == {WsActive} } {
Redirect_Self /cloudtk/
} else {
return [::cloudtk::Session $sock $suffix]
}
}
set noVNCpath {/kanaka/noVNC/vnc.html?path=cloudtk/$session&resize=remote&autoconnect=true}
switch -- $suffix {
"VNC" {
::cloudtk::Dynamic $sock $noVNCpath
}
default {
append pagehtml "<p>\n"
append pagehtml "Enter the Tk Application you wish to launch.\n<p>\n"
append pagehtml "<form action=$data(prefix)/VNC method=POST>\n"
append pagehtml "<input type=hidden name=session value=new>\n"
append pagehtml "<table>\n"
foreach d [glob [file join [file dirname $::Config(starkitTop)] Tk]/*] {
set Tkapp [file tail $d]
append pagehtml [::html::row $Tkapp "<input type=radio [html::radioValue Tk $Tkapp]>"]\n
}
# append pagehtml [::html::row "VNC Host" "<input type=text [html::formValue TCPhost]>"]\n
# append pagehtml [::html::row "VNC Port" "<input type=text [html::formValue TCPport]>"]\n
append pagehtml "</table>\n<p>\n<p>\n"
append pagehtml "<input type=submit>\n<p>\n</form>\n"
append pagehtml "</body>\n</html>"
Httpd_ReturnData $sock text/html "[::mypage::header "Tk Application"] $pagehtml [mypage::footer]"
}
}
}
# ::cloudtk::Session --
# This procedure control access to the websocket to TCP gateway via a Session ID
# via a Url query parameter.
proc ::cloudtk::Session {sock session} {
upvar #0 Httpd$sock data
# To get started register the socket as a websocket server.
::websocket::server $sock
# The callback procedure when a message/data is present.
::websocket::live $sock /cloudtk [list ::cloudtk::Gateway $session]
# Test the Http headers via data(headerlist) to see if it is a websocket request.
set wstest [::websocket::test $sock $sock /cloudtk $data(headerlist) $data(query)]
# If ::websocket::test returns 1 it's a valid websocket request so suspend the Http request
# in TclHtppd. Let the websocket library return the correct Http headers via the
# ::websocket::upgrade and take control.
if {$wstest == 1} {
Httpd_Suspend $sock 0
::websocket::upgrade $sock
} else {
Httpd_ReturnData $sock text/html "Not a valid Websocket connection!"
}
}
# ::cloudtk::Gateway --
# This procedure is called when the server
# can read data from the client
#
# Arguments: appended to the callback procedure by the Websocket library.
# sock The socket connection to the client
# type Type of message either:
# request (initial connection generated by the websocket library.)
# close
# disconnect
# binary
# text
# msg message or data
#
proc ::cloudtk::Gateway {session sock type msg} {
upvar #0 Session:$session state
# Uncomment the following line to view what's being sent from the client.
#puts "Gateway sock=$sock type=$type msg=$msg"
# In Tcl Websocket Library in tcllib there was a change in the type of connection label. In
# Version 1.3.1 the intial connection type was "request" in Version 1.4 it changed to "connect".
# Have kept both incase a different version is used.
switch $type {
request {
set state(type) WsActive
return [::cloudtk::SocketTCP $sock $session $state(TCPhost) $state(TCPport)]
}
connect {
set state(type) WsActive
return [::cloudtk::SocketTCP $sock $session $state(TCPhost) $state(TCPport)]
}
close { return }
disconnect {
Xdisplay_Close $state(Xdisplay) Xvnc
close $state(TCPsock)
Session_Destroy $session
unset ::Httpd$sock
unset ::websocket::Server_$sock
return
}
binary {
puts -nonewline $state(TCPsock) $msg
return
}
text {
return
}
}
}
# ::cloudtk::SocketTCP --
# This procedure connect via socket -async to the TCP host port.
proc ::cloudtk::SocketTCP {sock session TCPhost TCPport} {
upvar #0 Session:$session state
set state(TCPsock) [socket -async $TCPhost $TCPport]
fconfigure $state(TCPsock) -translation binary -blocking off -buffering none
fileevent $state(TCPsock) r [list ::cloudtk::ReceiveTCP $sock $session $state(TCPsock)]
}
# ::cloudtk::ReceiveTCP --
# This procedure receives data on the TCP socket and then
# resends it on the websocket via ::websocket::send
proc ::cloudtk::ReceiveTCP {sock session TCPsock} {
upvar #0 Session:$session state
set error [fconfigure $state(TCPsock) -error]
if {$error ne ""} {
::websocket::close $sock
} elseif {[eof $state(TCPsock)]} {
::websocket::close $sock
} else {
::websocket::send $sock binary [read $state(TCPsock)]
}
}
# ::cloudtk::Auth --
# This procedure is used in the callback of the .tclaccess
# files.
proc ::cloudtk::Auth {sock realm user pass} {
set file [file join $::Config(docRoot) cloudtk .tclaccess]
set ::auth${file}(htaccessp,userfile) $::Config(AuthUserFile)
# now check the Basic credentials
set crypt [AuthGetPass $sock $file $user]
set salt [string range $crypt 0 1]
set crypt2 [crypt $pass $salt]
if {[string compare $crypt $crypt2] != 0} {
return 0 ;# Not the right password
} else {
return 1
}
}
# ::cloudtk::AccessHook --
# This procedure is used via Url_AccessInstallPrepend to change
# the default behaviour of the authentication. It check if the
# the url starts with /cloudtk or /kanaka and allows access
# based on what is set in the AuthTargetFile.txt file.
proc ::cloudtk::AccessHook {sock url} {
global Doc
upvar #0 Httpd$sock data
variable Target
if {![string equal [file mtime $Target(AuthTargetFile,file)] $Target(AuthTargetFile,mtime)]} {
::cloudtk::AuthTarget
}
# Make sure the path doesn't sneak out via ..
# This turns the URL suffix into a list of pathname components
if {[catch {Url_PathCheck $data(suffix)} data(pathlist)]} {
Doc_NotFound $sock
return denied
}
# Figure out the directory corresponding to the domain, taking
# into account other document roots.
if {[regexp {^(/cloudtk|/kanaka|/favicon.ico|/images)} $url]} {
set directory [file join $Doc(root,/) cloudtk]
set suffix [Url_PathCheck [string trimleft $data(suffix) /]]
if {![regexp {.*(/)$} $suffix _ slash]} {
set slash ""
}
if {$Target(AuthTargetFile,VNC) == 0} {
if {[regexp {^(/cloudtk/|/kanaka/noVNC|/favicon.ico|/images/)} $url]} {
return ok
} elseif {[info exists ::Session:$suffix]} {
return ok
}
}
# Look for .tclaccess file in cloudtk directory.
# This controls access to cloudtk and kanaka
# directories.
set cookie [Auth_Check $sock $directory ""]
# Finally, check access
if {![Auth_Verify $sock $cookie]} {
return denied
} else {
return skip
}
} elseif {[regexp {^(/debug|/status)} $url]} {
return skip
} elseif {[regexp {^(/)} $url]} {
if {$Target(AuthTargetFile,Website) == 0} {
return ok
} else {
return skip
}
} else {
return skip
}
}
# ::cloudtk::AuthTarget --
# This procedure sets up the Auth Target file and gets its contents
# into an array. If the file doesn't exist it sets some defaults.
proc ::cloudtk::AuthTarget {} {
variable Target
set Target(AuthTargetFile,file) [file join [file dirname $::Config(starkitTop)] auth AuthTarget.txt]
if {![file exists $Target(AuthTargetFile,file)]} {
set fd [open $Target(AuthTargetFile,file) w]
puts $fd "VNC 0"
puts $fd "Website 0"
close $fd
unset fd
set Target(AuthTargetFile,VNC) "0"
set Target(AuthTargetFile,Website) "0"
set Target(AuthTargetFile,mtime) [file mtime $Target(AuthTargetFile,file)]
} else {
set Target(AuthTargetFile,mtime) [file mtime $Target(AuthTargetFile,file)]
set fd [open $Target(AuthTargetFile,file) r]
while {[gets $fd line] >= 0} {
set Target(AuthTargetFile,[lindex $line 0]) "[lindex $line 1]"
}
close $fd
unset fd
}
}
# ::cloudtk::Dynamic ---
# This procedure is run when a Host and Port is configured in the form. It checks
# to make sure that the previous page was a referer page from same server or
# source you configure.
# It checks a valid Session ID is created and not a crafted Session ID.
# Tests the Host and Port are valid before establishing the WebSocket and
# the TCP connection.
proc ::cloudtk::Dynamic {sock urlRedirect} {
upvar #0 Httpd$sock data
set session [Session_Match [Url_DecodeQuery $data(query)] WsInit {} 0]
if {$session eq ""} {
Httpd_ReturnData $sock text/html "<br><h2><b>Error message = Not a valid Session ID</b></h2>"
} else {
upvar #0 Session:$session state
# Xdisplay_SessionReap 90 WsInit
foreach {name value} [Url_DecodeQuery $data(query)] {
if {[string match $name session] == 1 } {
continue
} else {
set state($name) $value
}
}
set state(Xdisplay) [Xdisplay_Start 10 $session]
set state(TCPhost) 127.0.0.1
set state(TCPport) [expr {5900 + $state(Xdisplay)}]
Redirect_Self [subst $urlRedirect]
}
}
# ::cloudtk::TkPool --
# This procedure sets up TkPool in the Tk directory. It copies 2 files
# TkStartup.tcl and TkPool.tcl from the TclHttpd's custom directory into
# Tk/TkPool direcory of the Starkit.
proc ::cloudtk::TkPool {} {
set TkPool(dir) [file join [file dirname $::Config(starkitTop)] Tk TkPool]
set TkPool(custom,file) [file join $::Config(home) ../custom]/TkPool.tcl.custom
set TkPool(custom,start) [file join $::Config(home) ../custom]/TkStartup.tcl.custom
set TkPool(Tk,file) $TkPool(dir)/TkPool.tcl
set TkPool(Tk,start) $TkPool(dir)/TkStartup.tcl
if {![file isdirectory $TkPool(dir)]} {
file mkdir $TkPool(dir)
file copy -force $TkPool(custom,file) $TkPool(Tk,file)
# file copy -force $TkPool(custom,start) $TkPool(Tk,start)
set fd [open $TkPool(Tk,start) w]
set in [open $TkPool(custom,start) r]
set IN [read $in]
close $in
append IN "source $TkPool(Tk,file)\n"
append IN "\}"
puts $fd $IN
close $fd
} else {
return
}
}
# Generate the Auth file.
::cloudtk::AuthTarget
# Generate TkPool
::cloudtk::TkPool
Xdisplay.tcl
# Copyright (c) 2017 Jeff Smith
#
# See the file "license.terms" of TclHttpd for information on usage and redistribution
# of this file, and for a DISCLAIMER OF ALL WARRANTIES.
#
package require bgexec
# Xdisplay_Start --
#
# The purpose of the below procedure is to manage the X display number used
# when an Xvnc server is launched. Once the X display is lauched the Tk
# application and a Window Manager(if needed) is started that use the same X display.
# It also does checks to make sure that it won't use an active X display
# otherwise Xvnc won't start.
#
# Bgexec is used to launch the Xvnc server, Tk application and Window
# Manager as a background process.
proc Xdisplay_Start {{Xdisplay 1} session} {
upvar #0 Session:$session state
set Xincr 1
while {$Xincr == 1} {
if {[info exists ::X${Xdisplay}] || [file exists /tmp/.X${Xdisplay}-lock] || [file exists /tmp/.X11-unix/X$Xdisplay]} {
incr Xdisplay
} else {
set ::X${Xdisplay}(Session) Session:$session
set state(TCPhost) 127.0.0.1
set state(TCPport) [expr {5900 + $Xdisplay}]
set ::X${Xdisplay}(Start) [clock seconds]
set ::X${Xdisplay}(XvncClose) 0
trace variable ::X${Xdisplay}(XvncClose) aw "Xdisplay_Close $Xdisplay Xvnc"
set ::X${Xdisplay}(TkClose) 0
trace variable ::X${Xdisplay}(TkClose) aw "Xdisplay_Close $Xdisplay Tk"
set ::X${Xdisplay}(WmClose) 0
trace variable ::X${Xdisplay}(WmClose) aw "Xdisplay_Close $Xdisplay Wm"
set ::X${Xdisplay}(XvncPid) [bgexec ::X${Xdisplay}(XvncClose) -killsignal SIGTERM -linebuffered true -onerror "Xdisplay_XvncStart $Xdisplay $session" /usr/bin/Xvnc :$Xdisplay -localhost -desktop $state(Tk) SecurityTypes=None &]
set Xincr 0
}
}
Xdisplay_Reap
Xdisplay_SessionReap 90 WsInit
return $Xdisplay
}
# Xdisplay_Close --
#
# The purpose of the below procedure is to close all the processes associated
# with an X display. This is called once the process dies or we kill it
# and a trace variable is triggered. Setting the trace variable will kill
# the process under the control of bgexec.
proc Xdisplay_Close {Xdisplay type args} {
upvar #0 X$Xdisplay Xstate
switch $type {
Xvnc {
set Xstate(TkClose) 1
set Xstate(WmClose) 1
set Xstate(XvncClose) 1
}
Tk {
set Xstate(WmClose) 1
set Xstate(XvncClose) 1
}
Wm {
set Xstate(TkClose) 1
set Xstate(XvncClose) 1
}
}
}
# Xdisplay_Reap --
#
# The purpose of the procedure below is to clean up any X display variable
# that still exist in TclHttpd but no longer have an active X display. This
# produre is called after a new X display is started in Xdisplay_Start
proc Xdisplay_Reap {} {
foreach xd [info globals X*] {
upvar #0 $xd Xstate
foreach var {Xstate(XvncClose) Xstate(WmClose) Xstate(TkClose)} {
if {[info exists $var]} {
if {[regexp {^(EXITED|KILLED)} $Xstate(XvncClose)]} {
set Xstate(XvncClose) 1
set Xstate(WmClose) 1
set Xstate(TkClose) 1
}
} else {
set Xstate(XvncClose) 1
set Xstate(WmClose) 1
set Xstate(TkClose) 1
}
}
if { $Xstate(TkClose) && $Xstate(WmClose) && $Xstate(XvncClose) } {
Stderr "Reaping Xdisplay variable $xd"
unset Xstate
}
}
}
# Destroy all sessions older than a certain age (in seconds)
# age: time (in seconds) since the most recent access
# type: a regexp to mach session types with (defaults to all)
proc Xdisplay_SessionReap {age {type .*}} {
foreach id [info globals Session:*] {
upvar #0 $id session
set old [expr {[clock seconds] - $age}]
if {[regexp -- $type $session(type)] && $session(current) < $old} {
catch {interp delete $session(interp)}
Stderr "Reaping session $id"
if { [info exists session(TCPport)] } {
set Xdisplay [expr {$session(TCPport) - 5900}]
Xdisplay_Close $Xdisplay Xvnc
}
unset session
}
}
}
proc Xdisplay_XvncStart {Xdisplay session data} {
upvar #0 Session:$session state
# Wait until Xdisplay has started before loading Tk app and Window Manager
if {[string match "*Listening for VNC connections on * port *" $data]} {
set ::X${Xdisplay}(WmPid) [bgexec ::X${Xdisplay}(WmClose) -killsignal SIGTERM /usr/bin/matchbox-window-manager -display :$Xdisplay &]
set ::X${Xdisplay}(TkPid) [bgexec ::X${Xdisplay}(TkClose) -killsignal SIGTERM [info nameofexecutable] [file join [file dirname $::Config(starkitTop)] Tk $state(Tk) TkStartup.tcl] -display :$Xdisplay &]
}
}