Updated 2018-02-18 04:46:29 by anon

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 &]
     }
}