# webui.t | documentation

    #charset "us-ascii"

    /*
     *   Copyright (c) 2010 Michael J. Roberts.  All Rights Reserved.
     *
     *   This module defines some useful helper functions for implementing a
     *   TADS game with a Web-based user interface.
     */

    #include <tads.h>
    #include <tadsnet.h>
    #include <httpsrv.h>
    #include <httpreq.h>
    #include <strbuf.h>
    #include <file.h>

    /* ------------------------------------------------------------------------ */
    /*
     *   write a message to the system debug log
     */
    #define DbgMsg(msg) t3DebugTrace(T3DebugLog, msg)

    /* ------------------------------------------------------------------------ */
    /*
     *   Session timeout settings.  Times are in milliseconds.
     */

    /*
     *   Housekeeping interval.  We'll wait at least this long between
     *   housekeeping passes.
     */
    #define HousekeepingInterval  15000

    /*
     *   Session startup timeout.  When we first start up, we'll give our first
     *   client this long to establish a connection.  If we don't hear anything
     *   from a client within this interval, we'll assume that the client
     *   exited before it had a chance to set up its first connection, so we'll
     *   terminate the server.
     */
    #define SessionStartupTimeout  90000

    /*
     *   Ongoing session timeout.  After we've received our first connection,
     *   we'll terminate the server if we go this long without any active
     *   connections.
     */
    #define SessionTimeout  120000

    /*
     *   Event request timeout.  After an event request from a client has been
     *   sitting in the queue for longer than this limit, we'll send a "keep
     *   alive" reply to let the client know that we're still here.  The client
     *   will just turn around and send a new request.  This periodic handshake
     *   lets the client know we're still alive, and vice versa.
     */
    #define EventRequestTimeout  90000

    /*
     *   Client session timeout.  If we haven't seen a request from a given
     *   client within this interval, we'll assume that the client has
     *   disconnected, and we'll drop the client session state on this end.
     */
    #define ClientSessionTimeout 60000

    /* ------------------------------------------------------------------------ */
    /*
     *   Some handy Vector extensions
     */
    modify Vector
        push(ele) { append(ele); }
        pop() { return getAndRemove(length()); }
        shift() { return getAndRemove(1); }

        getAndRemove(idx)
        {
            /* get the element */
            local ret = self[idx];

            /* remove it */
            removeElementAt(idx);

            /* return the popped element */
            return ret;
        }

        clear()
        {
            if (length() > 0)
                removeRange(1, length());
        }
    ;

    /* ------------------------------------------------------------------------ */
    /*
     *   HTTPRequest extensions
     */
    modify HTTPRequest
        /*
         *   Send a reply, catching "socket disconnect" exceptions.  In most
         *   cases, server objects will want to use this method rather than the
         *   native sendReply() so that they won't have to handle disconnect
         *   exceptions manually.
         *
         *   Disconnect exceptions are common with HTTP: the protocol is
         *   stateless by design, so clients can close their sockets at any
         *   time.  Most modern browsers use HTTP 1.1, which allows the client
         *   to maintain an open socket indefinitely and reuse it serially for
         *   multiple requests, but browsers tend to close these reusable
         *   sockets after a few minutes of inactivity.
         *
         *   When this routine encounters a disconnected socket, it deletes the
         *   client session record for the request, and then otherwise ignores
         *   the error.  This generally makes the client's socket management
         *   transparent to the server, since if the client is still running
         *   they'll just connect again with a new socket and retry any lost
         *   requests.
         */
        sendReplyCatch(reply, contType?, stat?, headers?)
        {
            /* catch any errors */
            try
            {
                /* try sending the reply */
                sendReply(reply, contType, stat, headers);
            }
            catch (SocketDisconnectException sde)
            {
                /* notify the associated client session of the disconnect */
                local client = ClientSession.find(self);
                if (client != nil)
                    client.checkDisconnect();
            }
        }
    ;

    /* ------------------------------------------------------------------------ */
    /*
     *   Web UI Session object.  This keeps track of miscellaneous items
     *   associated with the game session.
     */
    transient webSession: object
        /*
         *   Get the full URL to the given resource.
         */
        getFullUrl(resname)
        {
            return 'http://<<server.getAddress()>>:<<
                server.getPortNum()>><<resname>>';
        }

        /*
         *   Connect to the UI.  By default, we ask the webMainWin object to
         *   establish a connection, and we save the server object internally
         *   for future reference.
         */
        connectUI(srv)
        {
            /* connect the UI to the main window object */
            webMainWin.connectUI(srv);

            /* save a reference to the server object */
            server = srv;

            /*
             *   set the zero point for the connection time - for timeout
             *   purposes, the countdown starts now, since we obviously can't
             *   have a connection before this point
             */
            lastClientTime = getTime(GetTimeTicks);
        }

        /*
         *   The session key.  This identifies the server as a whole, and is
         *   essentially an authentication mechanism that lets clients prove
         *   they got our address from an authorized source (rather than just
         *   stumbling across it via a port scan, say).  Clients must hand this
         *   to us on each request, either via a URL query parameter or via a
         *   cookie.  The normal setup (via WebResourceInit) is for the client
         *   to send us the key as a URL parameter on the initial request, at
         *   which point we'll pass it back as a set-cookie, removing the need
         *   for the client to include the key in subsequent URLs.
         *
         *   The key is just a random number that's long enough that an
         *   interloper couldn't hope to guess it.  We generate this on the
         *   first evaluation, and it remains fixed at that point for as long
         *   as we're running.
         */
        sessionKey = (sessionKey = generateRandomKey())

        /*
         *   The collaborative session key.  This is a secondary session key
         *   that allows additional users to connect to the session for
         *   collaborative play.
         */
        collabKey = (collabKey = generateRandomKey())

        /*
         *   Validate a session key sent from the client
         */
        validateKey(req, query)
        {
            /* get the key, either from the query string or from a cookie */
            local rkey = query['TADS_session'];
            if (rkey == nil)
                rkey = req.getCookie('TADS_session');

            /* if there's no key, it's an error */
            if (rkey == nil)
            {
                /* no key -> bad request */
                req.sendReply(400);
                return nil;
            }

            /* the key has to match either the main key or the collab key */
            if (rkey != sessionKey && rkey != collabKey)
            {
                /* invalid key -> forbidden */
                req.sendReply(403);
                return nil;
            }

            /* the session key is valid */
            return true;
        }

        /*
         *   The launcher's game ID.  This is the ID passed from the web server
         *   that launched the game, to let us know how the game is identified
         *   in the launcher database.  This is typically an IFDB TUID string.
         */
        launcherGameID = nil

        /*
         *   The launcher's user name.  This is passed from the web server that
         *   launched the game, to let us know the host user's screen name.  We
         *   use this as the user's default screen name in multi-user games.
         */
        launcherUsername = 'Host'

        /*
         *   The primary storage server session ID, for the user who launched
         *   the server.  If the user who launched the game logged in to a
         *   cloud storage server, this is the session ID that we use to
         *   transact business with the server on behalf of this logged-in
         *   user.  This token identifies and authenticates the user, but it's
         *   ephemeral and it's only valid for the current game server session,
         *   so it's not quite like a password.  This is the session for the
         *   launch user only; if other collaborative users join, they can get
         *   their own session IDs that will allow them to store files under
         *   their own private user folders on the server.
         */
        storageSID = nil

        /*
         *   Get the collaborative player launch URL.  This is a URL that the
         *   host can send to other players who wish to join the session as
         *   collaborative users.
         */
        getCollabUrl()
        {
            /* return the main window URL */
            return webSession.getFullUrl(
                '<<webMainWin.vpath>>?TADS_session=<<collabKey>>');
        }

        /* list of active client sessions (ClientSession objects) */
        clientSessions = static new transient Vector()

        /* add a client session */
        addClient(s)
        {
            clientSessions.append(s);
            lastClientTime = getTime(GetTimeTicks);
            everHadClient = true;
        }

        /* remove a client session */
        removeClient(s)
        {
            clientSessions.removeElement(s);
            lastClientTime = getTime(GetTimeTicks);
        }

        /* the HTTPServer object running our web session */
        server = nil

        /*
         *   Run housekeeping tasks.  The network event processor calls this
         *   periodically to let us perform background cleanup tasks.  Returns
         *   the system tick time of the next housekeeping run.
         */
        housekeeping()
        {
            /*
             *   if it hasn't been long enough yet since the last housekeeping
             *   run, skip this
             */
            if (getTime(GetTimeTicks) < hkTime)
                return hkTime;

            /* send keep-alives and check for dead client sessions */
            clientSessions.forEach(function(s)
            {
                /* send keep-alives to aged-out event requests */
                s.sendKeepAlive();

                /* check to see if the client has disconnected */
                s.checkDisconnect();
            });

            /* if there are no client sessions, check for server termination */
            if (clientSessions.length() == 0)
            {
                /*
                 *   There are no clients connected.  If we've exceeded the
                 *   maximum interval for running without a client, shut down
                 *   the server.  Use the initial connection time limit if
                 *   we've never had a client, otherwise use the ongoing idle
                 *   time limit.
                 */
                local limit = everHadClient
                    ? SessionTimeout : SessionStartupTimeout;

                if (getTime(GetTimeTicks) > lastClientTime + limit)
                {
                    /* no clients - shut down */
                    throw new QuittingException();
                }
            }
            else
            {
                /* we have a session as of right now */
                lastClientTime = getTime(GetTimeTicks);
            }

            /* update the housekeeping timer */
            return hkTime = getTime(GetTimeTicks) + HousekeepingInterval;
        }

        /* system time (ms ticks) of next scheduled housekeeping pass */
        hkTime = 0

        /* the last time we noticed that we had a client connected */
        lastClientTime = 0

        /* have we ever had a client connection? */
        everHadClient = nil
    ;

    /*
     *   Generate a random key.  This returns a 128-bit random number as a hex
     *   string.  This is designed for ephemeral identifiers, such as session
     *   keys.
     */
    generateRandomKey()
    {
        /* generate a long random hex string */
        return rand('xxxxxxxx%-xxxx%-xxxx%-xxxxxxxxxxxx%-xxxx');
    }

    /* ------------------------------------------------------------------------ */
    /*
     *   Client session.  This represents a connection to one browser (or other
     *   client application).  Each browser client is a separate session, so we
     *   create one instance of this class per connected browser.  Note that
     *   browser instances don't necessarily represent different users - a
     *   single user could open multiple browser windows on the same server.
     *
     *   We identify each browser instance via a session cookie, which we
     *   establish when the client connects.  The browser sends the cookie with
     *   each subsequent request, allowing us to tie the request to the browser
     *   session we previously set up.
     */
    class ClientSession: object
        construct(skey, ssid)
        {
            /* remember my session key and storage server SID */
            storageSID = ssid;

            /* note if we're a secondary (collaborative play) user */
            isPrimary = (skey == webSession.sessionKey);

            /* add me to the master list of sessions */
            webSession.addClient(self);

            /* note our last activity time */
            updateEventTime();

            /* create a UI preferences object */
            uiPrefs = new WebUIPrefs(self);
        }

        /* the UI preferences object for this session */
        uiPrefs = nil

        /*
         *   The client's "screen name" - this is the user-visible name that
         *   we'll show other users to identify commands and chat messages
         *   entered by this client.
         */
        screenName = ''

        /* set the default screen name for a client */
        setDefaultScreenName()
        {
            /*
             *   If this is the primary, the name is the launching user's
             *   screen name.  If not, it's 'Guest N', where N is the number of
             *   guest connections we have.
             */
            screenName = (isPrimary ? webSession.launcherUsername :
                          'Guest <<webSession.clientSessions.countWhich(
                              {x: !x.isPrimary})>>');
        }

        /* the client's IFDB user ID (a "TUID"), if logged in to IFDB */
        ifdbTuid = nil

        /* the list of pending event requests from this client */
        pendingReqs = perInstance(new Vector())

        /*
         *   The client's event queue.  When a server-to-client event occurs,
         *   we post it to each current client's queue.  When the client sends
         *   a get-event request, we satisfy it out of this queue.
         */
        pendingEvts = perInstance(new Vector())

        /*
         *   The client session key.  This identifies the client across
         *   requests.  We send this to the client as a cookie when they
         *   connect, so we get it back on each request.
         */
        clientKey = perInstance(generateRandomKey())

        /*
         *   The storage server session key for the user connected to this
         *   session, if any.  We can have multiple users logged in to the game
         *   in collaborative play mode, each with their own separate storage
         *   server session.  This allows each user to have their own private
         *   preference settings, saved games, etc.
         */
        storageSID = nil

        /*
         *   Am I the primary player?  This is true if the player connected
         *   using the primary session key.  Collaborative players join through
         *   the separate collaborative session key.
         */
        isPrimary = nil

        /*
         *   Is this session alive?  When we detect that the client has
         *   disconnected, we'll set this to nil.  When waiting for a client in
         *   a modal event loop, this can be used to terminate the wait if the
         *   client disconnects.
         */
        isAlive = true

        /*
         *   Last request time, in system ticks (ms).  We use this to determine
         *   how long it's been since we've heard from the client, for timeout
         *   purposes.  This is updated any time we receive a command or event
         *   request from the client, and each time we successfully send an
         *   event reply.
         */
        lastEventTime = 0

        /* update the last event time for this client */
        updateEventTime() { lastEventTime = getTime(GetTimeTicks); }

        /* class method: broadcast an event message to all connected clients */
        broadcastEvent(msg)
        {
            /* send the event to each client in our active list */
            webSession.clientSessions.forEach({ c: c.sendEvent(msg) });
        }

        /* send an event to this client */
        sendEvent(msg)
        {
            /* enqueue the event, then match it to a request if possible */
            pendingEvts.push(msg);
            processQueues();
        }

        /* receive an event request from the client */
        requestEvent(req)
        {
            /* enqueue the request, then match it to an event if possible */
            pendingReqs.push(new ClientEventRequest(req));
            processQueues();
        }

        /* flush outstanding events for this client */
        flushEvents()
        {
            /* discard any queued events */
            pendingEvts.clear();

            /* send no-op replies to any pending event requests */
            while (pendingReqs.length() > 0)
            {
                /* pull out the first request */
                local req = pendingReqs.shift();

                /*
                 *   Send a reply.  Ignore any errors: the client probably
                 *   canceled their side of the socket already, so we'll
                 *   probably get a socket error sending the reply.  Since
                 *   we're just canceling the request anyway, this is fine.
                 */
                try
                {
                    /* send a no-op reply */
                    req.req.sendReply('<?xml version="1.0"?><noOp></noOp>',
                                      'text/xml', 200);

                    /* update the successful communications time */
                    updateEventTime();
                }
                catch (Exception exc)
                {
                    /*
                     *   ignore errors - the client probably canceled their
                     *   side of the socket already, so we'll probably fail
                     *   sending the reply
                     */
                }
            }
        }

        /*
         *   Send a keep-alive reply to each pending request from this client
         *   that's been waiting for longer than the timeout interval.
         *
         *   Javascript clients in principle will wait indefinitely for an
         *   XmlHttpRequest to complete, but in practice browsers tend to set
         *   fairly long but finite time limits.  If the time limit is exceeded
         *   for a request, the client will fail the request with an error.  To
         *   prevent this, our main event loop (processNetRequests)
         *   periodically calls this routine if no other events have occurred
         *   recently.  We'll clear out the pending event request queue for
         *   each client by sending a no-op reply to each event.  This tells
         *   the client that the server is still alive and connected but has
         *   nothing new to report.
         */
        sendKeepAlive()
        {
            /*
             *   Send no-op replies to any requests that have been in the queue
             *   for longer than the maximum event wait interval.  New requests
             *   are added to the end of the queue, so the first item in the
             *   queue is the oldest.
             */
            local t = getTime(GetTimeTicks);
            while (pendingReqs.length() > 0 && t >= pendingReqs[1].reqTimeout)
            {
                /* pull out the oldest request */
                local req = pendingReqs.shift();

                try
                {
                    /* send a no-op reply */
                    req.req.sendReply(
                        '<?xml version="1.0"?><event><keepAlive/></event>',
                        'text/xml', 200,
                        ['Cache-control: no-store, no-cache, '
                         + 'must-revalidate, post-check=0, pre-check=0',
                         'Pragma: no-cache',
                         'Expires: Mon, 26 Jul 1997 05:00:00 GMT']);

                    /* successful communications, so note the last up time */
                    updateEventTime();
                }
                catch (Exception exc)
                {
                    /* couldn't send the reply - consider disconnecting */
                    checkDisconnect();
                }
            }
        }

        /* broadcast a downloadable file to all clients */
        broadcastDownload(desc)
        {
            /* add the download to all clients */
            webSession.clientSessions.forEach({c: c.addDownload(desc)});
        }

        /* add a download to this client */
        addDownload(desc)
        {
            /* add the download to the table */
            downloads[desc.resName] = desc;
        }

        /*
         *   Cancel a downloadable file.  Removes the file from the download
         *   list and notifies the client that the file is no longer available.
         */
        cancelDownload(desc)
        {
            /* remove the file from our table */
            downloads.removeElement(desc.resName);

            /* tell the client that the file is no longer available */
            webMainWin.sendWinEventTo(
                '<cancelDownload><<desc.resPath.htmlify()>></cancelDownload>',
                self);
        }

        /* this client's list of downloadable temporary files */
        downloads = perInstance(new LookupTable())

        /* get a list of all of my downloadable files */
        allDownloads() { return downloads.valsToList(); }

        /* process the request and response queues */
        processQueues()
        {
            /* keep going as long as we have requests and responses to pair up */
            while (pendingReqs.length() > 0 && pendingEvts.length() > 0)
            {
                /* pull the first element out of each list */
                local req = pendingReqs.shift();
                local evt = pendingEvts[1];

                /*
                 *   Answer the request with the event.  Since this is an API
                 *   request masquerading as a page view, we need to generate a
                 *   live reply to each new request to the same virtual
                 *   resource path.  So, include some headers to tell the
                 *   browser and any proxies not to cache the reply.
                 */
                try
                {
                    /* send the reply */
                    req.req.sendReply(
                        evt, 'text/xml', 200,
                        ['Cache-control: no-store, no-cache, '
                         + 'must-revalidate, post-check=0, pre-check=0',
                         'Pragma: no-cache',
                         'Expires: Mon, 26 Jul 1997 05:00:00 GMT']);

                    /* we've successfully sent the event - discard it */
                    pendingEvts.shift();

                    /* update the successful communications time */
                    updateEventTime();
                }
                catch (SocketDisconnectException sde)
                {
                    /* the socket has been disconnected - consider disconnecting */
                    checkDisconnect();
                }
                catch (Exception exc)
                {
                    /*
                     *   Ignore other errors, since we can just turn around and
                     *   send the event again in response to the next event
                     *   request from the client - no information will be lost,
                     *   since we still have the event in the queue.
                     */
                }
            }
        }

        /* wait for the queues to empty in preparation for shutting down */
        shutdownWait(timeout)
        {
            /* process network requests until all clients disconnect */
            processNetRequests({: webSession.clientSessions.length() == 0 },
                               timeout);
        }

        /*
         *   Check to see if the client is still alive.  If the client has no
         *   pending event requests, and we haven't heard from the client in
         *   more than the client session timeout interval, assume the client
         *   is no longer connected and kill the session object.
         *
         *   This should be called whenever a sending a reply to a request
         *   fails with a Socket Disconnect exception.  We also run this
         *   periodically during routine housekeeping to check for clients that
         *   haven't even bothered to send a request.
         */
        checkDisconnect()
        {
            /* if we don't have any pending requests, kill the session */
            if (pendingReqs.length() == 0
                && getTime(GetTimeTicks) >= lastEventTime + ClientSessionTimeout)
            {
                /* remove the client session from our master list */
                webSession.removeClient(self);

                /* the session is now dead */
                isAlive = nil;
            }
        }

        /*
         *   Class method: forcibly disconnect all clients.  This simply
         *   deletes the list of active clients and deletes any pending events
         *   in their queues.  This doesn't actually terminate their network
         *   connections, but simply clears out any pending work for each
         *   client that we've initiated on the server side.
         */
        disconnectAll()
        {
            /* delete all pending events for each client */
            webSession.clientSessions.forEach({ s: s.pendingReqs.clear() });

            /* delete the client list */
            webSession.clientSessions.clear();
        }

        /*
         *   Class method: Find a client session, given the session key or an
         *   HTTPRequest object.
         */
        find(key)
        {
            /*
             *   if the key is given as an HTTPRequest, find the key by URL
             *   parameter or cookie
             */
            if (dataType(key) == TypeObject && key.ofKind(HTTPRequest))
            {
                /* get the request */
                local req = key;

                /* try the URL parameters and cookies */
                if ((key = req.getQueryParam('TADS_client')) == nil
                    && (key = req.getCookie('TADS_client')) == nil)
                {
                    /* there's no client key, so there's no client */
                    return nil;
                }
            }

            /* find the client that matches the key string */
            return webSession.clientSessions.valWhich({ x: x.clientKey == key });
        }

        /*
         *   Get the primary session.  This is the session for the original
         *   initiating user (the "host" in a multi-user game).
         */
        getPrimary()
        {
            /* scan for a session with the host session key */
            return webSession.clientSessions.valWhich({ x: x.isPrimary });
        }
    ;

    /* ------------------------------------------------------------------------ */
    /*
     *   Client event request.  Each client session object keeps a queue of
     *   pending event requests, representing incoming "GET /webui/getEvent"
     *   requests that have yet to be answered.
     */
    class ClientEventRequest: object
        construct(req)
        {
            self.req = req;
            reqTimeout = getTime(GetTimeTicks) + EventRequestTimeout;
        }

        /* the underlying HTTPRequest object */
        req = nil

        /*
         *   The system time (ms ticks) when the request times out.  If we
         *   don't have an actual event to send in response by this time, the
         *   housekeeper will generate a no-op reply just to let the client
         *   know that we're still here.
         */
        reqTimeout = 0
    ;

    /* ------------------------------------------------------------------------ */
    /*
     *   A WebResource is a virtual file accessible via the HTTP server.  Each
     *   resource object has a path, which can be given as a simple string that
     *   must be matched exactly, or as a RexPattern object with a regular
     *   expression to be matched.  Each object also has a "processRequest"
     *   method, which the server invokes to answer the request when the path
     *   is matched.
     */
    class WebResource: object
        /*
         *   The virtual path to the resource.  This is the apparent URL path
         *   to this resource, as seen by the client.
         *
         *   URL paths follow the Unix file system conventions in terms of
         *   format, but don't confuse the virtual path with an actual file
         *   system path.  The vpath doesn't have anything to do with the disk
         *   file system on the server machine or anywhere else.  That's why we
         *   call it "virtual" - it's merely the apparent location, from the
         *   client's perspective.
         *
         *   When the server receives a request from the client, it looks at
         *   the URL sent by the client to determine which WebResource object
         *   should handle the request.  The server does this by matching the
         *   resource path portion of the URL to the virtual path of each
         *   WebResource, until it finds a WebResource that matches.  The
         *   resource path in the URL is the part of the URL following the
         *   domain, and continuing up to but not including any "?" query
         *   parameters.  The resource path always starts with a slash "/".
         *   For example, for the URL "http://192.168.1.15/test/path?param=1",
         *   the resource path would be "/test/path".
         *
         *   The virtual path can be given as a string or as a RexPattern.  If
         *   it's a string, a URL resource path must match the virtual path
         *   exactly, including upper/lower case.  If the virtual path is given
         *   as a RexPattern, the URL resource path will be matched to the
         *   pattern with the usual regular expression rules.
         */
        vpath = ''

        /*
         *   Process the request.  This is invoked when we determine that this
         *   is the highest priority resource object matching the request.
         *   'req' is the HTTPRequest object; 'query' is the parsed query data
         *   as returned by req.parseQuery().  The query information is
         *   provided for convenience, in case the result depends on the query
         *   parameters.
         */
        processRequest(req, query)
        {
            /* by default, just send an empty HTML page */
            req.sendReply('<html><title>TADS</title></html>', 'text/html', 200);
        }

        /*
         *   The priority of this resource.  If the path is given as a regular
         *   expression, a given request might match more than one resource.
         *   In such cases, the matching resource with the highest priority is
         *   the one that's actually used to process the request.
         */
        priority = 100

        /*
         *   The group this resource is part of.  This is the object that
         *   "contains" the resource, via its 'contents' property; any object
         *   will work here, since it's just a place to put the contents list
         *   for the resource group.
         *
         *   By default, we put all resources into the mainWebGroup object.
         *
         *   The point of the group is to allow different servers to use
         *   different sets of resources, or to allow one server to use
         *   different resource sets under different circumstances.  When a
         *   server processes a request, it does so by looking through the
         *   'contents' list for a group of its choice.
         */
        group = mainWebGroup

        /*
         *   Determine if this resource matches the given request.  'query' is
         *   the parsed query from the request, as returned by
         *   req.parseQuery().  'req' is the HTTPRequest object representing
         *   the request; you can use this to extract more information from the
         *   request, such as cookies or the client's network address.
         *
         *   This method returns true if the request matches this resource, nil
         *   if not.
         *
         *   You can override this to specify more complex matching rules than
         *   you could achieve just by specifying the path string or
         *   RexPattern.  For example, you could make the request conditional
         *   on the time of day, past request history, cookies in the request,
         *   parameters, etc.
         */
        matchRequest(query, req)
        {
            /* get the query path */
            local qpath = query[1];

            /* by default, we match GET */
            local verb = req.getVerb().toUpper();
            if (verb != 'GET' && verb != 'POST')
                return nil;

            /* if the virtual path a string, simply match the string exactly */
            if (dataType(vpath) == TypeSString)
                return vpath == qpath;

            /* if it's a regular expression, match the pattern */
            if (dataType(vpath) == TypeObject && vpath.ofKind(RexPattern))
                return rexMatch(vpath, qpath) != nil;

            /* we can't match other path types */
            return nil;
        }

        /*
         *   Send a generic request acknowledgment or reply.  This wraps the
         *   given XML fragment in an XML document with the root type given by
         *   the last element in our path name.  If the 'xml' value is omitted,
         *   we send "<ok/>" by default.
         */
        sendAck(req, xml = '<ok/>')
        {
            /*
             *   Figure the XML document root element.  If we have a non-empty
             *   path, use the last element of the path (as delimited by '/'
             *   characters).  Otherwise, use a default root of <reply>.
             */
            local root = 'reply';
            if (dataType(vpath) == TypeSString
                && vpath.length() > 0
                && rexSearch(vpath, '/([^/]+)$') != nil)
                root = rexGroup(1)[3];

            /* send the reply, wrapping the fragment in a proper XML document */
            sendXML(req, root, xml);
        }

        /*
         *   Send an XML reply.  This wraps the given XML fragment in an XML
         *   document with the given root element.
         */
        sendXML(req, root, xml)
        {
            req.sendReply('<?xml version="1.0"?>\<<<root>>><<xml>></<<root>>>',
                          'text/xml', 200);
        }
    ;

    /* ------------------------------------------------------------------------ */
    /*
     *   A resource file request handler.  This handles a request by sending
     *   the contents of the resource file matching the given name.
     *
     *   To expose a bundled game resource as a Web object that the client can
     *   access and download via HTTP, simply create an instance of this class,
     *   and set the virtual path (the vpath property) to the resource name.
     *   See coverArtResource below for an example - that object creates a URL
     *   for the Cover Art image so that the browser can download and display
     *   it.
     *
     *   You can expose *all* bundled resources in the entire game simply by
     *   creating an object like this:
     *
     *.     WebResourceResFile
     *.         vpath = static new RexPattern('/')
     *.     ;
     *
     *   That creates a URL mapping that matches *any* URL path that
     *   corresponds to a bundled resource name.  The library intentionally
     *   doesn't provide an object like this by default, as a security measure;
     *   the default configuration as a rule tries to err on the side of
     *   caution, and in this case the cautious thing to do is to hide
     *   everything by default.  There's really no system-level security risk
     *   in exposing all resources, since the only files available as resources
     *   are files you explicitly bundle into the build anyway; but even so,
     *   some resources might be for internal use within the game, so we don't
     *   want to just assume that everything should be downloadable.
     *
     *   You can also expose resources on a directory-by-directory basis,
     *   simply by specifying a longer path prefix:
     *
     *.     WebResourceResFile
     *.         vpath = static new RexPattern('/graphics/')
     *.     ;
     *
     *   Again, the library doesn't define anything like this by default, since
     *   we don't want to impose any assumptions about how your resources are
     *   organized.
     */
    class WebResourceResFile: WebResource
        /*
         *   Match a request.  A resource file resource matches if we match the
         *   virtual path setting for the resource, and the requested resource
         *   file exists.
         */
        matchRequest(query, req)
        {
            return inherited(query, req) && resExists(processName(query[1]));
        }

        /* process the request: send the resource file's contents */
        processRequest(req, query)
        {
            /* get the local resource name */
            local name = processName(query[1]);

            /* get the filename suffix (extension) */
            local ext = nil;
            if (rexSearch('%.([^.]+)$', name) != nil)
                ext = rexGroup(1)[3];

            local fp = nil;
            try
            {
                /* open the file in the appropriate mode */
                if (isTextFile(name))
                    fp = File.openTextResource(name);
                else
                    fp = File.openRawResource(name);
            }
            catch (FileException exc)
            {
                /* send a 404 error */
                req.sendReply(404);
                return;
            }

            /*
             *   If the file suffix implies a particular mime type, set it.
             *   There are some media types that are significant to browsers,
             *   but which the HTTPRequest object can't infer based on the
             *   contents, so as a fallback infer the media type from the
             *   filename suffix if possible.
             */
            local mimeType = browserExtToMime[ext];

            /*
             *   Send the file's contents.  Since resource files can be large
             *   (e.g., images or audio files), send the reply asynchronously
             *   so that we don't block other requests while the file is being
             *   downloaded.  Browsers typically download multimedia resources
             *   in background threads so that the UI remains responsive during
             *   large downloads, so we have to be prepared to handle these
             *   overlapped requests while a download proceeds.
             */
            req.sendReplyAsync(fp, mimeType);

            /* done with the file */
            fp.closeFile();
        }

        /* extension to MIME type map for important browser file types */
        browserExtToMime = static [
            'css' -> 'text/css',
            'js' -> 'text/javascript'
        ]

        /*
         *   Process the name.  This takes the path string from the query, and
         *   returns the resource file name to look for.  By default, we simply
         *   return the same name specified by the client, minus the leading
         *   '/' (since resource paths are always relative).
         */
        processName(n) { return n.substr(2); }

        /*
         *   Determine if the given file is a text file or a binary file.  By
         *   default, we base the determination solely on the filename suffix,
         *   checking the extension against a list of common file types.
         */
        isTextFile(fname)
        {
            /* get the extension */
            if (rexMatch('.*%.([^.]+)$', fname) != nil)
            {
                /* pull out the extension */
                local ext = rexGroup(1)[3].toLower();

                /*
                 *   check against common binary types - if it's not there,
                 *   assume it's text
                 */
                return (binaryExts.indexOf(ext) == nil);
            }
            else
            {
                /* no extension - assume binary */
                return nil;
            }
        }

        /* table of common binary file extensions */
        binaryExts = ['jpg', 'jpeg', 'png', 'mng', 'bmp', 'gif',
                      'mpg', 'mp3', 'mid', 'ogg', 'wav',
                      'pdf', 'doc', 'docx', 'swf',
                      'dat', 'sav', 'bin', 'gam', 't3', 't3v'];
    ;

    /*
     *   The resource handler for our standard library resources.  All of the
     *   library resources are in the /webuires resource folder.  This exposes
     *   everything in that folder as a downloadable Web object.
     */
    webuiResources: WebResourceResFile
        vpath = static new RexPattern('/webuires/')
    ;

    /* the special cover art resource */
    coverArtResource: WebResourceResFile
        vpath = static new RexPattern('/%.system/CoverArt%.(jpg|png)$')
    ;

    /*
     *   Session initializer resource.  This is a mix-in class designed to be
     *   used for a special resource that initializes the session.  Mix this
     *   with a WebResource class to set up the initializer.  When you connect
     *   to the client via connectWebUI(), point the client to this resource.
     *
     *   There are two elements to setting up the session.  First, we need to
     *   set the program session key as a cookie.  The client obtains this from
     *   the registration mechanism, whose purpose is to launch the game
     *   program and send the connection information back to the client.  The
     *   client sends this to us in the form of a URL parameter, TADS_session.
     *   This key is essentially for authentication, to make sure that the
     *   client that we're talking to is actually the client that launched the
     *   program: only that client would be able to get the key, because we
     *   invent it and send it to the registrar, and the registrar only sends
     *   it back to the client session it's already established on its end.
     *   This prevents port scanners from finding our open port and trying to
     *   crawl our "site" or otherwise access our services.
     *
     *   The second setup element is to create a client session key.  Whereas
     *   the program session key is for our entire service, the client session
     *   key is specific to this one connection.  If the user opens two browser
     *   windows on this server, each browser needs its own separate client
     *   session so that we can tell the traffic apart.  The client session key
     *   is simply another random key we generate, and again we pass it back to
     *   the client in a cookie.
     *
     *   The reason we set cookies for both of these session keys is that it
     *   lets the client pass the information back to us on subsequent requests
     *   without having to encode another parameter in every URL.  We set
     *   session cookies in both cases; the program session is for
     *   authentication purposes and so we don't want it to be stored or
     *   shared, and the client session key is explicitly to identify this one
     *   browser session, so it obviously shouldn't be shared across browser
     *   instances or sessions.
     *
     *   Note that instances should always provide a string (as opposed to a
     *   regular expression) for the virtual path (the 'vpath' property).  We
     *   have to send the path to the browser UI as part of the connection
     *   information, so we need a string we can send rather than a pattern to
     *   match.
     */
    class WebResourceInit: object
        /*
         *   Connect to the client.  The program should call this after
         *   creating its HTTPServer object, which you pass here as 'srv'.
         *   This establishes the client UI connection, generating the path to
         *   the start page.
         */
        connectUI(srv)
        {
            /*
             *   point the client to our start page, adding the session key as
             *   a query parameter
             */
            connectWebUI(srv, '<<vpath>>?TADS_session=<<webSession.sessionKey>>');
        }

        /*
         *   Process the request.  This sets up the program and client session
         *   keys as cookies.
         */
        processRequest(req, query)
        {
            /* get the session parameter from the query */
            local skey = query['TADS_session'];

            /* set the session cookie in the reply */
            if (skey)
                req.setCookie('TADS_session', '<<skey>>; path=/;');

            /* check for a client session */
            local ckey = req.getCookie('TADS_client');
            if (ckey == nil || ClientSession.find(ckey) == nil)
            {
                /*
                 *   There's no client session ID cookie, or the ID is invalid
                 *   or has expired.  Create a new client session.
                 */

                /* get the storage server session ID key from the request */
                local ssid = query['storagesid'];

                /* get the user name */
                local uname = query['username'];

                /*
                 *   if there's no storage server session, and they're
                 *   connecting under the primary server session key, use the
                 *   primary user's storage server session
                 */
                if (ssid == nil && skey == webSession.sessionKey)
                    ssid = webSession.storageSID;

                /* create the new session object */
                local client = new transient ClientSession(skey, ssid);

                /* if there's a user name, set it in the session */
                if (uname != nil)
                    client.screenName = uname;
                else
                    client.setDefaultScreenName();

                /* send the client session ID to the browser as a cookie */
                req.setCookie('TADS_client', '<<client.clientKey>>; path=/;');

                /* if this is a guest, alert everyone to the new connection */
                if (!client.isPrimary)
                    webMainWin.postSyntheticEvent('newGuest', client.screenName);
            }

            /* go return the underlying resource */
            inherited(req, query);
        }

        /* the HTPTServer for communicating with the client */
        server = nil
    ;

    /* ------------------------------------------------------------------------ */
    /*
     *   A WebResourceGroup is a container for WebResource objects.  When a
     *   server receives a request, it looks in its group list to find the
     *   resource object that will handle the request.
     */
    class WebResourceGroup: object
        /*
         *   Should this group handle the given request?  By default, we say
         *   yes if the server that received the request is associated with
         *   this group via the group's 'server' property.
         */
        isGroupFor(req)
        {
            /* get the request's server object */
            local srv = req.getServer();

            /*
             *   if this server matches our 'server' property, or is in the
             *   list of servers if 'server' is a list, we're the group for the
             *   request
             */
            return (srv == server
                    || (dataType(server) == TypeList
                        && server.indexOf(srv) != nil));
        }

        /*
         *   The priority of the group, relative to other groups.  If the same
         *   server matches multiple groups, this allows you to designate which
         *   group has precedence.  A higher value means higher priority.
         */
        priority = 100

        /*
         *   The HTTPServer object or objects this group is associated with.
         *   The general event processor uses this to route a request to the
         *   appropriate resource group, by finding the group that's associated
         *   with the server that received the request.
         *
         *   To associate a group with multiple servers, make this a list.
         */
        server = nil

        /* the WebResource objects in the group */
        contents = []

        /*
         *   Process a request.  This looks for the highest priority matching
         *   resource in the group, then hands the request to that resource for
         *   processing.
         */
        processRequest(req)
        {
            /* parse the query */
            local query = req.parseQuery();

            /*
             *   Check for the session ID.  The session ID is required either
             *   in a URL parameter or in a cookie.  If it's not present,
             *   reject the request with a "403 Forbidden" error, since the
             *   session is essentially an authentication token to tell us that
             *   the client is in fact the same user that launched the game.
             */
            if (!webSession.validateKey(req, query))
                return;

            /*
             *   Search our list for the first resource that matches this
             *   request.  The list is initialized in descending priority
             *   order, so the first match we find will be the one with the
             *   highest priority.
             */
            local match = contents.valWhich({res: res.matchRequest(query, req)});

            /* if we found a match, process it; otherwise return a 404 error */
            if (match != nil)
                match.processRequest(req, query);
            else
                req.sendReply(404);
        }

        /* class property: list of all WebResourceGroup objects */
        all = []
    ;

    /* ------------------------------------------------------------------------ */
    /*
     *   The default web resource group.  This is the default container for
     *   WebResource objects.
     */
    mainWebGroup: WebResourceGroup
        /* the default group matches any server, but with low priority */
        isGroupFor(req) { return true; }
        priority = 1
    ;

    /* ------------------------------------------------------------------------ */
    /*
     *   At startup, put each WebResource object into the contents list for its
     *   group.
     */
    PreinitObject
        execute()
        {
            /* build the contents list for each resource group */
            forEachInstance(WebResource, function(obj) {

                /* get the group object for the resource */
                local g = obj.group;

                /* if the group doesn't have a contents list yet, create one */
                if (g.contents == nil)
                    g.contents = [];

                /* add this resource to the contents list */
                g.contents += obj;
            });

            /* sort each group's contents list in priority order */
            forEachInstance(WebResourceGroup, function(obj) {

                /* sort the group's contents list */
                obj.contents = obj.contents.sort(
                    SortDesc, { a, b: a.priority - b.priority });

                /* add this group to the master list of groups */
                WebResourceGroup.all += obj;
            });

            /* sort the groups in descending order of priority */
            WebResourceGroup.all = WebResourceGroup.all.sort(
                SortDesc, { a, b: a.priority - b.priority });
        }
    ;

    /* ------------------------------------------------------------------------ */
    /*
     *   Guest connection request.  This enables "switchboard" applications on
     *   remote servers that keep track of multi-user game sessions, to show
     *   users available sessions and connect new users to those sessions.
     *
     *   The first step in setting up a switchboard is for the game server to
     *   register itself with the switchboard by sending a request on startup.
     *   That part is external to us - that's not handled within the game
     *   program but rather within the web server script that launches the
     *   game.  Here, then, we simply assume that this work is already done.
     *
     *   The second step is that the switchboard needs to check back with the
     *   game server from time to time to see if it's still alive - essentially
     *   a "ping" operation.  We handle that here: if we respond to the
     *   request, we're obviously still alive.
     *
     *   The third step is that we need to send the switchboard a URL that lets
     *   secondary users ("guests") connect to the game session.  We handle
     *   that here as well: our reply body is the client connection URL.
     */
    guestConnectPage: WebResource
        vpath = '/webui/guestConnect'
        processRequest(req, query)
        {
            /* send the collaborative connection URL */
            req.sendReply(webSession.getCollabUrl(), 'text/plain', 200);
        }
    ;

    /* ------------------------------------------------------------------------ */
    /*
     *   getEvent request.  This is the mechanism we use to "send" events to
     *   the client.  The client sends a getEvent request to us, and we simply
     *   put it in a queue - we don't send back any response immediately.  As
     *   soon as we want to send an event to the client, we go through the
     *   queue of pending getEvent requests, and reply to each one with the
     *   event we want to send.
     */
    eventPage: WebResource
        vpath = '/webui/getEvent'
        processRequest(req, query)
        {
            /* find the client */
            local c = ClientSession.find(req);

            /* if we found the client session object, send it the request */
            if (c != nil)
                c.requestEvent(req);
            else
                req.sendReply(400);
        }

        /* broadcast an event message to each client */
        sendEvent(msg)
        {
            /* build the full XML message */
            msg = '<?xml version="1.0"?><event><<msg>></event>';

            /* send it to each client */
            ClientSession.broadcastEvent(msg);
        }

        /* send an event to a particular client */
        sendEventTo(msg, client)
        {
            /* build the full XML message */
            msg = '<?xml version="1.0"?><event><<msg>></event>';

            /* send it to the given client */
            client.sendEvent(msg);
        }
    ;

    /*
     *   Flush events.  This cancels any pending event requests for the client.
     *   The client can use this after being reloaded to flush any outstanding
     *   event requests from a past incarnation of the page.
     */
    flushEventsPage: WebResource
        vpath = '/webui/flushEvents'
        processRequest(req, query)
        {
            /* find the client */
            local c = ClientSession.find(req);

            /* if we found the client, send it the flush request */
            if (c != nil)
                c.flushEvents();

            /*
             *   acknowledge the request; it's okay if we didn't find the
             *   client session, since this just means there are no events to
             *   flush for this client
             */
            sendAck(req);
        }
    ;

    /*
     *   getState request.  The web page can send this to get a full accounting
     *   of the current state of the UI.  It does this automatically when first
     *   loaded, and again when the user manually refreshes the page.
     *
     *   We handle this by asking the main window to generate its state.
     */
    uiStatePage: WebResource
        vpath = '/webui/getState'
        processRequest(req, query)
        {
            /* get the window making the request */
            local w = webMainWin.winFromPath(query['window']);
            local client = ClientSession.find(req);

            /* if we found the window, send the reply */
            if (w)
            {
                /* send the uiState reply for the window */
                sendXML(req, 'uiState', w.getState(client));
            }
            else
            {
                /* no window - send an error reply */
                req.sendReply(406);
            }
        }
    ;

    /* ------------------------------------------------------------------------ */
    /*
     *   Process network requests.  Continues until doneFunc() returns true, or
     *   a timeout or error occurs.  If we return because doneFunc() returned
     *   true, we'll return nil.  Otherwise, we'll return the NetEvent that
     *   terminated the wait.
     */
    processNetRequests(doneFunc, timeout?)
    {
        /* if there's a timeout, figure the ending time */
        local endTime = (timeout != nil ? getTime(GetTimeTicks) + timeout : nil);

        /* keep going until the 'done' function returns true */
        while (doneFunc == nil || !doneFunc())
        {
            try
            {
                /* get the next housekeeping time */
                local hkTime = webSession.hkTime;

                /*
                 *   figure the time to the next timeout - stop at the caller's
                 *   ending time, or the next housekeeping time, whichever is
                 *   sooner
                 */
                local tf = (endTime != nil && endTime < hkTime ? endTime : hkTime);

                /* figure the time remaining to the next timeout */
                local dt = max(0, tf - getTime(GetTimeTicks));

                /*
                 *   Flush any pending output.  If we're waiting for user
                 *   input, this ensures that the last output is visible in the
                 *   UI while we await the next user action.
                 */
                flushOutput();

                /* get the next network event */
                local evt = getNetEvent(dt);

                /* see what we have */
                switch (evt.evType)
                {
                case NetEvRequest:
                    /*
                     *   This is an incoming network request from a client.
                     *   Check the protocol type of the request object.
                     */
                    if (evt.evRequest.ofKind(HTTPRequest))
                    {
                        /*
                         *   HTTP request - process it through the appropriate
                         *   web resource group.  First, find the group that
                         *   wants to handle the request.
                         */
                        local req = evt.evRequest;
                        local group = WebResourceGroup.all.valWhich(
                            { g: g.isGroupFor(req) });

                        /*
                         *   if there's a client associated with the request,
                         *   update its last activity time
                         */
                        local client = ClientSession.find(req);
                        if (client != nil)
                            client.updateEventTime();

                        /* if we found a group, let it handle the request */
                        if (group != nil)
                        {
                            try
                            {
                                /* send the request to the group for processing */
                                group.processRequest(req);
                            }
                            catch (Exception exc)
                            {
                                /*
                                 *   Unhandled exception - something went wrong
                                 *   in the server, so the appropriate reply is
                                 *   500 Internal Server Error.  Send the
                                 *   exception message with the reply.
                                 */
                                local msg =
                                    'Unhandled exception processing request: '
                                    + exc.getExceptionMessage().specialsToText();
                                req.sendReply(msg, 'text/plain', 500);
                            }
                        }
                        else
                        {
                            /* no group - send back a 404 error */
                            req.sendReply(404);
                        }
                    }
                    break;

                case NetEvTimeout:
                    /*
                     *   Timeout.  Always try running housekeeping on a
                     *   timeout; the housekeeper will ignore this unless the
                     *   time has actually come.
                     */
                    hkTime = webSession.housekeeping();

                    /*
                     *   Check for a caller timeout.  If the caller's end time
                     *   has arrived, pass the timeout back to the caller as a
                     *   timeout event.
                     */
                    if (endTime != nil && getTime(GetTimeTicks) >= endTime)
                        return evt;

                    /*
                     *   it wasn't a caller timeout, so it must have been an
                     *   internal housekeeping timeout, which we just handled;
                     *   simply continue looping
                     */
                    break;

                case NetEvUIClose:
                    /*
                     *   UI Closed.  This tells us that the user has manually
                     *   closed the UI window.  This only happens in the local
                     *   stand-alone configuration, where the "browser" is an
                     *   integrated part of the interpreter application,
                     *   simulating the traditional TADS interpreter setup
                     *   where the whole program is a single application
                     *   running on one machine.  In this setup, closing the UI
                     *   window should dismiss the whole application, since
                     *   that's the convention on virtually all GUIs.
                     */

                    /* disconnect all clients */
                    ClientSession.disconnectAll();

                    /* quit by signaling a "quit game" event */
                    throw new QuittingException();
                }
            }
            catch (SocketDisconnectException sdx)
            {
                /* the client has closed its connection; ignore this */
            }
        }

        /* indicate that the 'done' condition was triggered */
        return nil;
    }

    /* ------------------------------------------------------------------------ */
    /*
     *   Web Window tracker.  This is a game object that controls and remembers
     *   the state of a "window" in the browser user interface.  By "window",
     *   we basically mean an HTML page, which might reside at the top level of
     *   the browser itself, or inside an IFRAME element within an enclosing
     *   page.
     *
     *   Each WebWindow class corresponds to a particular HTML page that we
     *   serve the client.  The HTML page is the expression of the window in
     *   the browser, and the WebWindow object is the expression of the same
     *   information in the game program.  The two are different facets of the
     *   same conceptual UI object.  The reason we need the two separate
     *   expressions is that the server controls everything, but the client has
     *   to do the actual display work, and the two parts of the program speak
     *   different languages - the server is TADS, and the client is HTML.
     *
     *   The WebWindow object on the server lets us easily reconstruct the UI
     *   state in a newly opened browser window, or when the user performs a
     *   page refresh.  This object's job is to send information to the client
     *   on demand that allows the client to display the page in its current
     *   state.
     *
     *   Note that a given WebWindow/HTML page combination can be used more
     *   than once within the same UI.  The pages defined in the library are
     *   designed to be generic and reusable, so you might use the same window
     *   class more than once for different purposes within the UI.  The
     *   library pages can also be subclassed, by subclassing the WebWindow
     *   object and creating a customized copy of the corresponding HTML page
     *   resource.
     */
    class WebWindow: WebResourceResFile
        /*
         *   The URL path to the window's HTML definition file, as seen by the
         *   browser.  For the pre-defined library window types, we expose the
         *   HTML file in the root of the URL namespace - e.g., "/main.htm".
         *   The files are actually stored in the /webuires folder, but we
         *   expose them to the browser as though they were in the root folder
         *   to make embedded object references on the pages simpler.  The
         *   browser figures the path to an embedded object relative to the
         *   containing page, so by placing the containing page in the root
         *   folder, embedded object paths don't have to worry about
         *   referencing parent folders.
         */
        vpath = nil

        /*
         *   The window's actual source location, as a resource path.  A given
         *   WebWindow subclass corresponds to a particular HMTL page, since
         *   the class and the page are facets of the same conceptual object
         *   (one facet is the browser expression, the other is the game
         *   program expression).
         */
        src = nil

        /* process a request path referencing me into my actual resource path */
        processName(n) { return src; }

        /*
         *   Resolve a window path name.  For container windows, this should
         *   search the sub-windows for the given path.  By default, we match
         *   simply if the path matches our name.
         */
        winFromPath(path)
        {
            return path == name ? self : nil;
        }

        /*
         *   Flush the window.  This sends any buffered text to the UI.
         */
        flushWin() { }

        /*
         *   Write text to the window.  Subclasses with stream-oriented APIs
         *   must override this.
         */
        write(txt) { }

        /*
         *   Clear the window.  Subclasses must override this.
         */
        clearWindow() { }

        /*
         *   Get the window's current state.  This returns a string containing
         *   an XML fragment that describes the state of the window.  This
         *   information is sent to the HTML page when the browser asks for the
         *   current layout state when first loaded or when the page is
         *   refreshed.  The XML format for each subclass is specific to the
         *   Javascript on the class's HTML page.
         */
        getState(client) { return ''; }

        /* send an event related to this window to all clients */
        sendWinEvent(evt)
        {
            /*
             *   send the event message, adding a <window> parameter to
             *   identify the source
             */
            eventPage.sendEvent('<window><<pathName>></window><<evt>>');
        }

        /* send a window event to a specific client */
        sendWinEventTo(evt, client)
        {
            eventPage.sendEventTo('<window><<pathName>></window><<evt>>', client);
        }

        /* specialsToHtml context */
        sthCtx = perInstance(new SpecialsToHtmlState())

        /* the name of this window */
        name = nil

        /* the full path name of this window, in "win.sub.sub" format */
        pathName = nil
    ;

    /* ------------------------------------------------------------------------ */
    /*
     *   Layout Window.  This is a specialized Web Window tracker for our
     *   layout page type, which is displayed using the resource file
     *   webuires/layout.htm.  This page is designed as a container of more
     *   specialized sub-window pages; its job is to divide up the window space
     *   into IFRAME elements that display the sub-windows, and to manage the
     *   geometry of the IFRAMEs.
     *
     *   The layout page is primarily designed to be the top-level page of the
     *   web UI.  The idea is to set up a layout page as the navigation URL for
     *   the browser, so the layout page fills the browser window.  You then
     *   arrange your functional windows within the layout page - a command
     *   window, a status line window, etc.  This arrangement is similar to
     *   banner window in HTML TADS, but IFRAMEs are considerably more
     *   flexible; for example, they don't have to tile the main window, and
     *   you can size them in the full range of units CSS provides.
     *
     *   Layout windows aren't limited to the top level, though.  Since you can
     *   put any HTML page within an IFRAME, you can put another layout window
     *   within an IFRAME, to further subdivide the space inside the IFRAME.
     */
    class WebLayoutWindow: WebWindow
        /*
         *   Resolve a window path name
         */
        winFromPath(path)
        {
            /* get the first element and the rest of the path */
            local idx = path.find('.');
            if (idx == nil)
                idx = path.length() + 1;

            /* pull out the first element and the rest */
            local head = path.substr(1, idx - 1);
            local tail = path.substr(idx + 1);

            /* if the first element doesn't match our name, it's not a match */
            if (head != name)
                return nil;

            /* if that's the end of the path, we have our match */
            if (tail == '')
                return self;

            /* match the rest of the path against our children */
            foreach (local w in frames)
            {
                local match = w[1].winFromPath(tail);
                if (match != nil)
                    return match;
            }

            /* no match */
            return nil;
        }

        /*
         *   Create a new window within the layout.  This creates an IFRAME in
         *   the browser, laid out according to the 'pos' argument, and
         *   displays the given window object within the frame.
         *
         *   If the window already exists, this updates the window with the new
         *   layout settings.
         *
         *   'win' is a WebWindow object that will be displayed within the
         *   IFRAME.  This method automatically loads the HTML resource from
         *   the WebWindow into the new IFRAME.
         *
         *   'name' is the name of the window.  Each window within a layout
         *   must have a distinct name.  This allows you to refer to the
         *   dimensions of other windows in 'pos' parameters.  The name should
         *   be alphanumeric.
         *
         *   'pos' is the layout position for the new frame.  This is a string
         *   in this format: 'left, top, width, height', where 'left' is the
         *   horizontal position of the top left corner, 'top' is the vertical
         *   position of the top left corner, 'width' is the width of the
         *   window, and 'height' is the height.  Each element can be specified
         *   as a Javascript-style arithmetic expression.  Within the
         *   expression, you can use a mix of any of the following:
         *
         *.   123   - a number, representing a number of pixels on the display
         *.   5em   - 5 'em' units, relative to the main BODY font in the window
         *.   5en   - 5 'en' units in the main BODY font
         *.   5ex   - 5 'ex' units in the main BODY font
         *.   window.width - the width in pixels of the enclosing window
         *.   window.height - the height in pixels of the enclosing window
         *.   50%   - percentage of the width or height of the enclosing window
         *.   content.width - the width in pixels of the contents of the frame
         *.   content.height - the height in pixels of the contents of the frame
         *.   x.left   - horizontal coordinate of leftmost edge of window 'x'
         *.   x.right  - horizontal coordinate of rightmost edge of window 'x'
         *.   x.top    - vertical coordinate of top edge of window 'x'
         *.   x.bottom - vertical coordinate of bottom edge of window 'x'
         *.   x.width  - width in pixels of window 'x'
         *.   x.height - height in pixels of window 'x'
         *
         *   The "window" dimensions refer to the *enclosing* window.  If this
         *   layout window is the main page of the UI, this is simply the
         *   browser window itself.  For a layout window nested within another
         *   frame, this is the enclosing frame.
         *
         *   Percentage units apply to the enclosing window.  When a percentage
         *   is used in the 'left' or 'width' slot, it applies to the width of
         *   the enclosing window; in the 'top' or 'height' slot, it applies to
         *   the height.
         *
         *   The "content" dimensions refer to the contents of the frame we're
         *   creating.  This is the size of the contents as actually laid out
         *   in the browser.
         *
         *   "x.left" and so on refer to the dimensions of other frames *within
         *   this same layout window*.  'x' is the name of another window
         *   within the same layout, as specified by the 'name' argument given
         *   when the window was created.
         */
        createFrame(win, name, pos)
        {
            /* set the window's internal name to its full path name */
            win.name = name;
            win.pathName = self.name + '.' + name;

            /* add the window to our list */
            frames[name] = [win, pos];

            /* notify the UI */
            sendWinEvent('<subwin>'
                         + '<name><<name>></name>'
                         + '<pos><<pos>></pos>'
                         + '<src><<win.vpath.htmlify()>></src>'
                         + '</subwin>');
        }

        /*
         *   Flush this window.  For a layout window, we simply flush each
         *   child window.
         */
        flushWin()
        {
            /* flush each child window */
            frames.forEach({w: w[1].flushWin()});
        }

        /*
         *   Get the state.
         */
        getState(client)
        {
            /* build an XML fragment describing the list of frames */
            local s = '';
            frames.forEachAssoc(function(name, info) {
                s += '<subwin><name><<name>></name>'
                    + '<pos><<info[2]>></pos>'
                    + '<src><<info[1].vpath.htmlify()>></src>'
                    + '</subwin>';
            });

            /* return the completed state object */
            return s;
        }

        /*
         *   The table of active frames within this layout.  This table is
         *   keyed by window name; each entry is a list of [win, pos], where
         *   'win' is the WebWindow object for the window, and 'pos' is its
         *   position parameter.
         */
        frames = perInstance(new LookupTable(16, 32))

        /* my virtual path and the actual resource file location */
        vpath = '/layoutwin.htm'
        src = 'webuires/layoutwin.htm'
    ;

    /* ------------------------------------------------------------------------ */
    /*
     *   Command Window.  This object keeps track of the state of command
     *   window within the web UI.
     */
    class WebCommandWin: WebWindow
        /*
         *   Write to the window
         */
        write(txt)
        {
            /* add the text to the output buffer */
            outbuf.append(txt);
        }

        /*
         *   Flush the buffers
         */
        flushWin()
        {
            /* get the current output buffer */
            local txt = toString(outbuf).specialsToHtml(sthCtx);

            /* add it to the state buffer (text since last input) */
            textbuf.append(txt);

            /* send the text to the client via an event */
            sendWinEvent('<say><<txt.htmlify()>></say>');

            /* we've now processed the pending output buffer */
            outbuf.deleteChars(1);
        }

        /*
         *   Read a line of input in this window.  Blocks until the reply is
         *   received.  Returns nil on timeout.
         */
        getInputLine(timeout?)
        {
            /* flush buffered output */
            flushWin();

            /* clear out the last input */
            lastInput = nil;
            lastInputClient = nil;
            lastInputReady = nil;

            /* set the UI state */
            mode = 'inputLine';
            isInputOpen = true;

            /* send the inputLine event to the client */
            sendWinEvent('<inputLine/>');

            /* process network events until we get our input or we time out */
            processNetRequests(
                {: lastInputReady || webMainWin.syntheticEventReady() },
                timeout);

            /* move the current textbuf contents to the scrollback list */
            textbufToScrollback(lastInput);

            /* back to 'working' mode */
            mode = 'working';

            /* check the result */
            if (lastInput != nil)
            {
                /* we got a reply - mark the input line as closed in the UI */
                isInputOpen = nil;

                /* reset to the start of the line in the output context */
                sthCtx.resetLine();

                /* remember the source of the command */
                webMainWin.curCmdClient = lastInputClient;

                /* return the Line Input event */
                return [InEvtLine, lastInput];
            }
            else if (webMainWin.syntheticEventReady())
            {
                /* there's a synthetic event available - return it */
                return webMainWin.getSyntheticEvent();
            }
            else
            {
                /* we didn't get a reply, so we timed out */
                return [InEvtTimeout, ''];
            }
        }

        /*
         *   Cancel an input line that was interrupted by a timeout
         */
        cancelInputLine(reset)
        {
            /* if the input line is open, send the cancel event */
            if (isInputOpen)
            {
                /* send the cancel event to the client */
                sendWinEvent('<cancelInputLine reset="<<
                    reset ? 'yes' : 'no'>>" />');

                /* the input line is closed */
                isInputOpen = nil;

                /* reset to the start of the line in the output context */
                sthCtx.resetLine();
            }
        }

        /*
         *   Get the state of this command window
         */
        getState(client)
        {
            return '<mode><<mode>></mode><scrollback>'
                + scrollback.join()
                + '<sbitem><text><<toString(textbuf).htmlify()>></text></sbitem>'
                + '</scrollback>';
        }

        /*
         *   Receive input from the client
         */
        receiveInput(req, query)
        {
            /* remember the text */
            lastInput = query['txt'];

            /* remember the source of the input */
            lastInputClient = ClientSession.find(req);

            /* set the input-ready flag so we exit the modal input loop */
            lastInputReady = true;

            /* get the user who entered the command */
            local user = (lastInputClient != nil
                          ? lastInputClient.screenName : '');

            /* tell any other windows listening in about the new input */
            sendWinEvent('<closeInputLine>'
                         + (lastInput != nil ?
                            '<text><<lastInput.htmlify()>></text>' : '')
                         + '<user><<user.htmlify()>></user>'
                         + '</closeInputLine>');

            /* reset to the start of the line in the output context */
            sthCtx.resetLine();
        }

        /*
         *   Clear the window
         */
        clearWindow()
        {
            /* flush the output buffer */
            flushWin();

            /*
             *   clear the transcript - if the user refreshes the browser
             *   window, we want to show a blank window
             */
            textbuf.deleteChars(1);
            scrollback.clear();

            /* send a clear window event */
            sendWinEvent('<clearWindow/>');

            /* reset to the start of the line in the output context */
            sthCtx.resetLine();
        }

        /*
         *   Move the current text buffer contents to the scrollback list.  If
         *   this would make the scrollback list exceed the limit, we'll drop
         *   the oldest item.
         *
         *   'cmd' is the command line text of the last input.  We include this
         *   in the srollback list with special tagging so that the UI can
         *   display it in a custom style, if it wants.
         */
        textbufToScrollback(cmd)
        {
            /* get the current buffer, tagged as a <text> entry in the list */
            local t = '<text><<toString(textbuf).htmlify()>></text>';

            /* add the command line tagged as <input> */
            if (cmd != nil)
                t += '<input><<cmd.htmlify()>></input>';

            /* add the buffer to the scrollback list */
            scrollback.append('<sbitem><<t>></sbitem>');

            /* if this pushes us past the limit, drop the oldest item */
            if (scrollback.length() > scrollbackLimit)
                scrollback.shift();

            /* clear text buffer */
            textbuf.deleteChars(1);
        }

        /*
         *   Show a "More" prompt
         */
        showMorePrompt()
        {
            /* flush the output buffer */
            flushWin();

            /* send a "More" prompt event to the UI */
            sendWinEvent('<morePrompt/>');
            mode = 'morePrompt';
            moreMode = true;

            /* process events until More mode is done */
            processNetRequests({: !moreMode });

            /* return the default mode */
            mode = 'working';
        }

        /*
         *   receive notification from the client that the user has responded
         *   to the More prompt, ending the pause
         */
        endMoreMode()
        {
            /* no longer in More mode */
            moreMode = nil;
        }

        /* main window text buffer since last input read */
        textbuf = perInstance(new StringBuffer(4096))

        /*
         *   Scrollback list.  After each input, we add the contents of
         *   'textbuf' to this list.  If this pushes the list past the limit,
         *   we drop the oldest item.  This is used to reconstruct a reasonable
         *   amount of scrollback history when a new client connects, or when
         *   an existing client refreshes the page.
         */
        scrollback = perInstance(new Vector())

        /*
         *   The scrollback limit, as a number of command inputs.  Each input
         *   interaction adds one item to the scrollback list.  When the number
         *   of items in the list exceeds the limit set here, we drop the
         *   oldest item.
         */
        scrollbackLimit = 10

        /* pending output buffer, since last flush */
        outbuf = perInstance(new StringBuffer(4096))

        /* the text of the last input line we received from the client */
        lastInput = nil

        /* is input ready? */
        lastInputReady = nil

        /* client session who sent the last input line */
        lastInputClient = nil

        /*
         *   Is an input line open?  This is true between sending an
         *   <inputLine> event and either getting a reply, or explicitly
         *   sending a close or cancel event.
         */
        isInputOpen = nil

        /* flag: we're in More mode */
        moreMode = nil

        /*
         *   Current UI mode.  This is 'working' if the program is running and
         *   in the process of computing and/or generating output; 'inputLine'
         *   if we're waiting for the user to enter a line of input;
         *   'morePrompt' if we're showing a "More" prompt.
         */
        mode = 'working'

        /* my virtual path, and the actual resource file location */
        vpath = '/cmdwin.htm'
        src = 'webuires/cmdwin.htm'
    ;

    /*
     *   input-line event page
     */
    inputLinePage: WebResource
        vpath = '/webui/inputLine'
        processRequest(req, query)
        {
            /* find the window */
            local w = webMainWin.winFromPath(query['window']);

            /* dispatch to the window if we found it */
            if (w != nil)
            {
                /* send the input to the window */
                w.receiveInput(req, query);

                /* acknowledge the request */
                sendAck(req);
            }
            else
                req.sendReply(406);
        }
    ;

    /*
     *   "More" prompt done event page
     */
    morePromptDonePage: WebResource
        vpath = '/webui/morePromptDone'
        processRequest(req, query)
        {
            /* find the target winodw */
            local w = webMainWin.winFromPath(query['window']);
            if (w != nil)
            {
                /* release More mode in the server window */
                w.endMoreMode();

                /* acknowledge the request */
                sendAck(req);
            }
            else
                req.sendReply(406);
        }
    ;

    /* ------------------------------------------------------------------------ */
    /*
     *   Set Preferences command
     */
    setPrefsPage: WebResource
        vpath = '/webui/setPrefs'
        processRequest(req, query)
        {
            /* get the request body */
            local f = req.getBody();
            if (f == nil)
            {
                errorReply(req, 'Error saving preferences: no data received');
                return;
            }
            else if (f == 'overflow')
            {
                errorReply(req, 'Error saving preferences: message too large');
                return;
            }

            /* get the client session for the request */
            local cli = ClientSession.find(req);
            if (cli == nil)
            {
                errorReply(req, 'Error saving preferences: missing session');
                return;
            }

            /* process the file through the profile reader */
            cli.uiPrefs.readSettings(f);

            /* save the updated settings */
            cli.uiPrefs.saveSettings();

            /* done with the file */
            f.closeFile();

            /* done - acknowledge the request */
            sendAck(req);
        }

        /* send an error as the reply to a request, formatted into XML */
        errorReply(req, msg)
        {
            sendXML(req, 'reply', '<error><<msg.htmlify()>></error>');
        }
    ;

    /*
     *   UI Settings list.  This represents a named UI settings profile in the
     *   Web UI.  A profile is a list of name/value pairs.
     *
     *   Most of the name keys are style IDs defined in the javascript for on
     *   the UI side - see main.js.  These style IDs are arbitrary keys we
     *   define to identify UI elements - "mainFont" for the main font name,
     *   "statusBkg" for the status-line window's background color, etc.  Each
     *   style ID generally corresponds to a dialog control widget in the
     *   preferences dialog in the javascript UI, and also corresponds to one
     *   or more CSS style selectors.  The mapping from style ID to CSS is
     *   defined in the UI javascript (see prefsMapper in main.js).
     *
     *   The non-style key "profileName" is the user-visible name of this
     *   profile.  Internally, we refer to profiles using ID values, which are
     *   arbitrary identifiers generated by the UI when it creates a new
     *   profile (it currently uses integer keys).
     */
    class WebUIProfile: object
        construct(id)
        {
            self.profileID = id;
            self.settings = new LookupTable();
        }

        /* set a preference item in the profile */
        setItem(id, val)
        {
            settings[id] = val;
        }

        /* call a callback for each style: func(id, val) */
        forEach(func)
        {
            settings.forEachAssoc(func);
        }

        /* internal ID of the profile */
        profileID = ''

        /* table of style value strings, keyed by style ID */
        settings = nil
    ;

    /*
     *   Web UI preferences.  This object contains the in-memory version of the
     *   display style preferences file.
     *
     *   Each client session has its own copy of this object, because each
     *   client can be associated with a different user, and each user has
     *   their own preferences file.
     */
    class WebUIPrefs: object
        construct(c)
        {
            /* remember our client session object */
            clientSession = c;

            /* load the initial settings from the user's config file */
            loadSettings();
        }

        /* read the settings file */
        loadSettings()
        {
            /* open the preferences file; do nothing if that fails */
            local f = openSettingsFile(FileAccessRead);
            if (f == nil)
                return;

            /* read the settings from the file */
            readSettings(f);

            /* done with the file */
            f.closeFile();
        }

        /* read settings from a file */
        readSettings(f)
        {
            /* set up our table of profiles */
            local pros = profileTab = new LookupTable();

            /* we don't have a current profile selection yet */
            curProfile = nil;

            /* read the file */
            for (;;)
            {
                /* read the next line */
                local l = f.readFile();
                if (l == nil)
                    break;

                /* if it's a valid line, process it */
                if (rexMatch(curProPat, l) != nil)
                {
                    /* current profile setting */
                    curProfile = rexGroup(1)[3];
                }
                else if (rexMatch(proItemPat, l) != nil)
                {
                    /* style item definition - pull out the parts */
                    local proid = rexGroup(1)[3];
                    local key = rexGroup(2)[3];
                    local val = rexGroup(3)[3];

                    /* if the profile isn't in the table yet, add it */
                    local pro = pros[proid];
                    if (pro == nil)
                        pros[proid] = pro = new WebUIProfile(proid);

                    /* add this item to the table */
                    pro.setItem(key, val);
                }
            }
        }

        /* current profile ID pattern - current-profile:xxx */
        curProPat = static new RexPattern('current-profile=([^\n]*)\n?$')

        /* setting ID pattern for profile items - nnn.xxx=yyy */
        proItemPat = static new RexPattern('([^.]+)%.([^=]+)=([^\n]*)\n?$')

        /* save the current settings to the user's config file */
        saveSettings()
        {
            /* if there's no profile table, there's nothing to write */
            if (profileTab == nil)
                return;

            try
            {
                /* open the preferences file; do nothing if that fails */
                local f = openSettingsFile(FileAccessWrite);
                if (f == nil)
                    return;

                /* write the current profile ID */
                f.writeFile('current-profile=<<curProfile>>\n');

                /* write the profiles */
                profileTab.forEachAssoc(function(id, pro)
                {
                    /* write each element of this profile */
                    pro.forEach(function(key, val)
                    {
                        /* write this profile item */
                        f.writeFile('<<id>>.<<key>>=<<val>>\n');
                    });
                });

                /* done with the file */
                f.closeFile();
            }
            catch (Exception e)
            {
                /*
                 *   couldn't save the file; this isn't fatal, so just log an
                 *   error event
                 */
                webMainWin.postSyntheticEvent(
                    'logError', 'An error occurred saving your setting changes.
                        (Details: <<e.getExceptionMessage()>>)');
            }
        }

        /* open the settings file */
        openSettingsFile(access)
        {
            /* get the filename; abort if we don't have a file */
            local name = getSettingsFile();
            if (name == nil)
                return nil;

            /* open the file */
            try
            {
                /* open the file and return the handle */
                return File.openTextFile(name, access, 'ascii');
            }
            catch (Exception exc)
            {
                /* failed to open the file */
                return nil;
            }
        }

        /* get the settings file path */
        getSettingsFile()
        {
            /* if we're in local stand-alone mode, use the local Web UI file */
            if (getLaunchHostAddr() == nil)
                return WebUIPrefsFile;

            /* if there's a storage server session, the file is on the server */
            if (clientSession.storageSID != nil)
                return '~<<clientSession.storageSID>>/special/2';

            /*
             *   We're in client/server mode, but there's no storage server.
             *   In this mode, we don't have any server-side location to store
             *   files, so we can't save or restore the configuration.
             */
            return nil;
        }

        /* get the current settings as XML, to send to the web UI */
        getXML()
        {
            /* if there's no profile table, there are no settings to return */
            if (profileTab == nil)
                return '';

            /* create a buffer for the results */
            local s = new StringBuffer();

            /* add the current profile */
            s.append('<currentProfile><<curProfile>></currentProfile>');

            /* add each profile's contents */
            profileTab.forEachAssoc(function(id, pro)
            {
                /* open this profile section */
                s.append('<profile><id><<id>></id>');

                /* add each style element */
                pro.forEach(function(id, val) {
                    s.append('<item>'
                             + '<id><<id.htmlify()>></id>'
                             + '<value><<val.htmlify()>></value>'
                             + '</item>');
                });

                /* close this profile's section */
                s.append('</profile>');
            });

            /* return the result as an XML string */
            return toString(s);
        }

        /* the client session for this preference list */
        clientSession = nil

        /*
         *   profile table - this is a LookupTable of WebUIProfile objects
         *   keyed by profile name
         */
        profileTab = nil

        /* current active profile selected by the user */
        curProfile = nil
    ;

    /* ------------------------------------------------------------------------ */
    /*
     *   Status line window
     */
    class WebStatusWin: WebWindow
        /* my request path and actual resource path */
        vpath = '/statwin.htm'
        src = 'webuires/statwin.htm'

        /*
         *   Set the room and score/turns portions of the status line.  This
         *   sets the left side of the status line to the 'room' text (which
         *   can contain HTML markups), and the right side to the the
         *   score/turns values, if present.  If the turn counter is omitted
         *   but the score value is present, we'll just show the score value;
         *   otherwise we'll format these as "score/turns".  If no score value
         *   is present, we'll leave the right side blank.
         */
        setStatus(room, score?, turns?)
        {
            /* set up the room text in the left portion */
            local msg = '<div class="statusleft"><<room>></div>';

            /*
             *   format the right side: 'score/turns', 'score', or empty,
             *   depending on what the caller specified
             */
            local rt = (score != nil ? toString(score) : '')
                + (turns != nil ? '/' + turns : '');

            /* if there's a right side, wrap it with right alignment */
            if (rt != '')
                msg += '<div class="statusright"><<rt>></div>';

            /*
             *   our left/right divisions are floats, which some browsers don't
             *   count against the container size; so add a clear:all division
             *   to make sure we count the height properly
             */
            msg += '<div style="clear: both;"></div>';

            /* set the text */
            setStatusText(msg);
        }

        /*
         *   Set the text of the status line.  This sets the entire status
         *   window to the given HTML text, without any additional formatting.
         */
        setStatusText(msg)
        {
            /* setting new text, so reset the stream context */
            sthCtx.resetState();

            /* set the new text */
            txt_.deleteChars(1);
            txt_.append(msg.specialsToHtml(sthCtx));

            /* note that we have new text to send to the UI */
            deltas_ = true;
        }

        /* add text to the status line */
        write(msg)
        {
            /* add the text */
            txt_.append(msg.specialsToHtml(sthCtx));

            /* note that we have new text to send to the UI */
            deltas_ = true;
        }

        /* clear the window */
        clearWindow()
        {
            setStatusText('');
        }

        /* flush pending text to the window */
        flushWin()
        {
            /* if we have any deltas since the last flush, send changes */
            if (deltas_)
            {
                /*
                 *   Send a set-text event, along with a resize.  The resize
                 *   ensures that the status line adjusts to the current
                 *   content size, assuming the window is using automatic
                 *   content-height sizing.
                 */
                sendWinEvent(
                    '<text><<toString(txt_).htmlify()>></text><resize/>');

                /* reset the deltas counter */
                deltas_ = nil;
            }
        }

        /*
         *   Refigure the window size.  The status line is generally set up to
         *   be automatically sized to its contents, which requires that we
         *   tell the UI when it's time to recalculate the layout to reflect
         *   the current contents after a change.
         */
        resize() { sendWinEvent('<resize/>'); }

        /* get the current state to send to the browser */
        getState(client)
        {
            return '<text><<toString(txt_).htmlify()>></text>';
        }

        /* do we have any deltas since the last flush? */
        deltas_ = nil

        /* the current status message */
        txt_ = perInstance(new StringBuffer(512))
    ;

    /* ------------------------------------------------------------------------ */
    /*
     *   The standard "main window" of our user interface.  This is the game
     *   object that represents the default initial HTML page that the player's
     *   web browser connects to.  We build this out of three base classes:
     *
     *   - WebResourceInit, because this is the starting page that the browser
     *   initially connects to.  This class does the initial handshaking to set
     *   up the session.
     *
     *   - WebResourceResFile, because we store the HTML for the page in a
     *   resource file.  This class does the work of sending the resource
     *   file's contents to the browser.
     *
     *   - WebLayoutWindow, because the default main page is also a layout
     *   window, which is basically a container for IFRAME elements where we
     *   plug in the sub-windows that make up the game's user interface.
     *
     *   Games can customize the front page in any way they like.  If you want
     *   to customize the HTML of the main page, you can substitute a different
     *   HTML (.htm) file, and change the processName() method to return the
     *   name of that file.  If you want to use something other than a layout
     *   window as the front page, you can simply replace this whole class.
     */
    transient webMainWin: WebResourceInit, WebLayoutWindow, WebResourceResFile
        /*
         *   match the webuires directory path as the URL path, but map this to
         *   main.htm as the underlying resource name
         */
        vpath = '/'
        processName(n) { return 'webuires/main.htm'; }

        /* the top window is always called "main" */
        name = 'main'
        pathName = 'main'

        /* the window title */
        title = 'TADS'

        /* set the window title */
        setTitle(title)
        {
            /* remember the title */
            self.title = title;

            /* update the browser */
            sendWinEvent('<setTitle><<title.htmlify()>></setTitle>');
        }

        /*
         *   Client session for current command line input.  Certain modal
         *   interactions, such as file dialogs, are directed only to the
         *   client that initiated the current command.
         */
        curCmdClient = nil

        /* get the state */
        getState(client)
        {
            /* get the inherited layout state */
            local s = inherited(client);

            /* add the preference settings */
            s += '<prefs><<client.uiPrefs.getXML()>></prefs>';

            /* add the window title */
            s += '<title><<title.htmlify()>></title>';

            /* add the input event, input file, and input dialog states */
            s += inputEventState;
            s += inputDialogState;
            s += menuSysState;

            /* if this is the command client, include the file dialog state */
            if (client == curCmdClient)
                s += fileDialogState;

            /* add the downloadable file list */
            s += client.allDownloads().mapAll(
                { x: x.isReady
                  ? '<offerDownload><<x.resPath.htmlify()>></offerDownload>'
                  : '' }
                ).join();

            /* return the result */
            return s;
        }

        /* wait for an input event */
        getInputEvent(timeout)
        {
            /* flush buffered output */
            flushWin();

            /* send the get-event request to the client */
            inputEventState = '<getInputEvent/>';
            inputEventResult = nil;
            sendWinEvent(inputEventState);

            /* process network events until we get an input event */
            processNetRequests({: inputEventResult != nil }, timeout);

            /* check what happened */
            if (inputEventResult)
            {
                /* we got an input event - return it */
                return inputEventResult;
            }
            else
            {
                /*
                 *   There's no result, so we timed out.  Tell the UI to cancel
                 *   the input event mode.
                 */
                inputEventState = '';
                sendWinEvent('<cancelInputEvent/>');

                /* return a timeout event */
                return [InEvtTimeout];
            }
        }

        /* receive an input event */
        receiveInputEvent(req, query)
        {
            local typ = query['type'];
            local param = query['param'];

            switch (typ)
            {
            case 'key':
                inputEventResult = [InEvtKey, param];
                break;

            case 'href':
                inputEventResult = [InEvtHref, param];
                break;

            default:
                inputEventResult = [InEvtEof];
                break;
            }
        }

        /* show the file selector dialog */
        getInputFile(prompt, dialogType, fileType, flags)
        {
            /* mappings from FileTypeXxx to storage manager type codes */
            local typeMap = [
                FileTypeLog -> 'log',
                FileTypeData -> 'dat',
                FileTypeCmd -> 'cmd',
                FileTypeText -> 'txt',
                FileTypeBin -> 'bin',
                FileTypeT3Image -> 't3',
                FileTypeT3Save -> 't3v',
                * -> '*'];

            /* get the storage server session for the command client */
            local sid = curCmdClient.storageSID;

            /*
             *   If we have a session, get the URL to the storage server file
             *   selection dialog.  Note that we don't specify the session in
             *   the URL, since the storage server separately maintains the
             *   session with the client via a cookie.
             */
            local url = nil;
            if (sid != nil)
                url = getNetStorageURL(
                    'fileDialog?filetype=<<typeMap[fileType]>>'
                    + '&dlgtype=<<dialogType == InFileOpen ? 'open' : 'save'>>'
                    + '&prompt=<<prompt.urlEncode()>>'
                    + '&ret=<<webSession.getFullUrl('/webui/inputFile')>>');

            /*
             *   if there's no storage server, try using a client PC upload or
             *   download
             */
            if (url == nil)
                return getInputFileFromClient(prompt, dialogType, fileType, flags);

            /*
             *   set up the file dialog state description (store it in a
             *   property, so that we can send it again as part of a state
             *   request if the client window is reloaded from scratch)
             */
            fileDialogState =
                '<fileDialog>'
                +   '<prompt><<prompt.htmlify()>></prompt>'
                +   '<dialogType><<dialogType == InFileOpen
                    ? 'open' : 'save'>></dialogType>'
                +   '<fileType><<typeMap[fileType]>></fileType>'
                +   '<url><<url.htmlify()>></url>'
                +   '</fileDialog>';

            /*
             *   Presume that the dialog will be canceled.  If the user clicks
             *   the close box on the dialog, the dialog will be dismissed
             *   without ever sending us a result.  This counts as a
             *   cancellation, so it's the default result.
             */
            fileDialogResult = [InFileCancel];

            /* send the request to the current command client */
            sendWinEventTo(fileDialogState, curCmdClient);

            /* process network events until the dialog is dismissed */
            processNetRequests(
                {: fileDialogState == '' || !curCmdClient.isAlive });

            /* return the dialog result */
            return fileDialogResult;
        }

        /*
         *   Get an input file from the client PC.  We'll attempt to upload or
         *   download a file from/to the client PC, using a local temporary
         *   file for the actual file operations.  This is a special form of
         *   the input file dialog that we use when we're not connected to a
         *   storage server.
         */
        getInputFileFromClient(prompt, dialogType, fileType, flags)
        {
            /* use the appropriate procedure for Open vs Save */
            if (dialogType == InFileOpen)
            {
                /*
                 *   Opening an existing file.  Show a dialog asking the user
                 *   to select a file to upload.  If the user uploads a file,
                 *   we'll use the local temp file created by the upload as the
                 *   result of the input dialog.
                 */
                fileDialogState =
                    '<uploadFileDialog>'
                    + '<prompt><<prompt.htmlify()>></prompt>'
                    + '</uploadFileDialog>';

                /* presume the user will cancel the dialog */
                fileDialogResult = [InFileCancel];

                /* send the request to the UI */
                sendWinEventTo(fileDialogState, curCmdClient);

                /* process network events until the dialog is dismissed */
                processNetRequests(
                    {: fileDialogState == '' || !curCmdClient.isAlive });

                /* return the file dialog result */
                return fileDialogResult;
            }
            else if (dialogType == InFileSave)
            {
                /*
                 *   Saving to a new file.  The caller is asking the user to
                 *   choose a name for a file that the caller *intends* to
                 *   create, but hasn't yet created.  HTTP can only do a
                 *   download as a transaction, though - we can offer an
                 *   existing file to the user to download, and the user will
                 *   choose the location for the download as part of that
                 *   transaction.  We don't have that file yet because the
                 *   standard protocol is to ask the user for the name first,
                 *   then write the selected file.
                 *
                 *   So, we have to play a little game.  We don't ask the user
                 *   to save anything right now, because there's no file to
                 *   offer for download yet.  Instead, we silently generate a
                 *   local temporary file name and return that to our caller.
                 *   Our caller will create the temp file and write its data.
                 *
                 *   The trick is that we wrap this temp file name in a
                 *   DownloadTempFile object.  When the caller finishes
                 *   writing, it will call closeFile() on the temp file.  The
                 *   system will pass that along to use by calling closeFile()
                 *   on our DownloadTempFile object.  At that point, we'll have
                 *   a completed temporary file, so we'll pop up a download box
                 *   at that point offering the file for download to the user.
                 *   The user can then select a local file on the client side,
                 *   and we'll send the temporary file's contents as the
                 *   download to store in the client file.
                 */
                return [InFileSuccess,
                        tempFileDownloadPage.addFile(fileType, curCmdClient)];
            }
            else
            {
                /* unknown dialog type */
                return [InFileFailure];
            }
        }

        /*
         *   Offer a file for download to the client.  'file' is a
         *   DownloadTempFile object previously created by a call to
         *   inputFile().
         */
        offerDownload(file)
        {
            /*
             *   send the "offer download" event to the client - the client
             *   will display an iframe with the file as an "attachment", which
             *   will trigger the browser to offer it as a download
             */
            sendWinEventTo(
                '<offerDownload><<file.resPath.htmlify()>></offerDownload>',
                curCmdClient);
        }

        /* receive a file selection from the file selector dialog */
        receiveFileSelection(req, query)
        {
            /* get the filename from the query */
            local f = query['file'];
            local desc = query['desc'];
            local cancel = (query['cancel'] != nil);
            local err = query['error'];

            /*
             *   Set the file dialog result list. This uses the same format as
             *   the native inputFile() routine: the first element is the
             *   status code (Success, Failure, Cancel); on success, the second
             *   element is the filename string.  We add two bits of
             */
            if (err != nil)
                fileDialogResult = [InFileFailure, err];
            else if (cancel || f == nil || f == '')
                fileDialogResult = [InFileCancel];
            else
            {
                /* we got a filename - add the SID prefix if necessary */
                if (rexMatch('~[^/]+/', f) == nil)
                    f = '~<<curCmdClient.storageSID>>/<<f>>';

                /* success - return the filename */
                fileDialogResult = [InFileSuccess, f];

                /* if there's a description string, return that as well */
                if (desc != nil)
                    fileDialogResult += desc;
            }
        }

        /* receive notification that the file dialog has been closed */
        inputFileDismissed()
        {
            /* clear the file dialog state */
            fileDialogState = '';
        }

        /* receive a file upload from the file upload dialog */
        receiveFileUpload(req, query)
        {
            /* get the file from the request */
            local fields = req.getFormFields(), file;
            if (fields == 'overflow')
            {
                /* error */
                fileDialogResult = [InFileFailure, libMessages.webUploadTooBig];
            }
            else if (fields != nil && (file = fields['file']) != nil)
            {
                /* got it - save the contents to a local temporary file */
                local tmpfile = new TemporaryFile();
                local fpTmp = nil;
                try
                {
                    /* open the temp file */
                    fpTmp = File.openRawFile(tmpfile, FileAccessWrite);

                    /* make sure the input file is in raw binary mode */
                    local fpIn = file.file;
                    fpIn.setFileMode(FileModeRaw);

                    /* copy the contents */
                    fpTmp.writeBytes(fpIn);

                    /* close the temp file */
                    fpTmp.closeFile();

                    /*
                     *   Set the dialog result to the temp file.  The system
                     *   will automatically delete the underlying file system
                     *   object when the garbage collector deletes the
                     *   TemporaryFile.
                     */
                    fileDialogResult = [InFileSuccess, tmpfile];
                }
                catch (Exception exc)
                {
                    /* error - the dialog failed */
                    fileDialogResult = [InFileFailure, exc.getExceptionMessage()];

                    /* close and delete the temporary file */
                    if (fpTmp != nil)
                        fpTmp.closeFile();
                    if (tmpfile != nil)
                        tmpfile.deleteFile();
                }
            }
            else
            {
                /* no file - consider it a cancellation */
                fileDialogResult = [InFileCancel];
            }

            /* the dialog is dismissed */
            fileDialogState = '';
        }

        /* show a generic inputDialog dialog */
        getInputDialog(icon, prompt, buttons, defaultButton, cancelButton)
        {
            /*
             *   if one of the standard button sets was selected, turn it into
             *   a list of localized button names
             */
            switch (buttons)
            {
            case InDlgOk:
                buttons = [libMessages.dlgButtonOk];
                break;

            case InDlgOkCancel:
                buttons = [libMessages.dlgButtonOk, libMessages.dlgButtonCancel];
                break;

            case InDlgYesNo:
                buttons = [libMessages.dlgButtonYes, libMessages.dlgButtonNo];
                break;

            case InDlgYesNoCancel:
                buttons = [libMessages.dlgButtonYes, libMessages.dlgButtonNo,
                           libMessages.dlgButtonCancel];
                break;
            }

            /* get a suitable localized title corresponding to the icon tyep */
            local title = [InDlgIconNone -> libMessages.dlgTitleNone,
                           InDlgIconWarning -> libMessages.dlgTitleWarning,
                           InDlgIconInfo -> libMessages.dlgTitleInfo,
                           InDlgIconQuestion -> libMessages.dlgTitleQuestion,
                           InDlgIconError -> libMessages.dlgTitleError][icon];

            /* build the dialog xml description */
            inputDialogState =
                '<inputDialog>'
                +  '<icon><<icon>></icon>'
                +  '<title><<title>></title>'
                +  '<prompt><<prompt.htmlify>></prompt>'
                +  '<buttons>'
                +     buttons.mapAll({b: '<button><<b.htmlify()>></button>'})
                      .join('')
                +  '</buttons>'
                +  '<defaultButton><<defaultButton>></defaultButton>'
                +  '<cancelButton><<cancelButton>></cancelButton>'
                + '</inputDialog>';

            /* send the request to the client */
            inputDialogResult = nil;
            sendWinEvent(inputDialogState);

            /* process network events until we get an answer */
            processNetRequests({: inputDialogResult != nil });

            /* return the dialog result */
            return inputDialogResult;
        }

        /* receive a selection from the input dialog */
        receiveInputDialog(req, query)
        {
            /* note the result button */
            inputDialogResult = toInteger(query['button']);

            /* if we didn't get a valid button index, select button 1 */
            if (inputDialogResult == 0)
                inputDialogResult = 1;

            /* the dialog is no longer open */
            inputDialogState = '';
        }

        /*
         *   Post a synthetic event.  A synthetic event looks like a regular UI
         *   or network event, but is generated internally instead of being
         *   delivered from the underlying browser or network subsystems.
         *
         *   'id' is a string giving the event type.  The remaining parameters
         *   are up to each event type to define.
         */
        postSyntheticEvent(id, [params])
        {
            /* add it to the synthetic event queue */
            synthEventQueue.append([id] + params);
        }

        /* is a synthetic event ready? */
        syntheticEventReady() { return synthEventQueue.length() > 0; }

        /* pull the next synthetic event from the queue */
        getSyntheticEvent() { return synthEventQueue.shift(); }

        /*
         *   file dialog state - this is the XML describing the currently open
         *   file dialog; if the dialog isn't open, this is an empty string
         */
        fileDialogState = ''

        /*
         *   file dialog result - this is a result list using the same format
         *   as the native inputFile() function
         */
        fileDialogResult = nil

        /*
         *   input dialog state - this is the XML describing an input dialog
         *   while a dialog is running, or an empty string if not
         */
        inputDialogState = ''

        /* input dialog result - this is the button number the user selected */
        inputDialogResult = nil

        /* input event state */
        inputEventState = ''

        /* input event result */
        inputEventResult = nil

        /* menuSys state - menu system state (maintained by the menu module) */
        menuSysState = ''

        /*
         *   Synthetic event queue.  This is a vector of synthetic events, set
         *   up in the [type, params...] format that the system inputEvent()
         *   function and related functions use.  The 'type' code for a
         *   synthetic evente is a string instead of the numeric identifier
         *   that the system functions use.
         */
        synthEventQueue = static new transient Vector()
    ;

    /* ------------------------------------------------------------------------ */
    /*
     *   Temporary file download page.  This page serves temporary files
     *   created via inputFile() as HTTP downloads to the client.
     */
    transient tempFileDownloadPage: WebResource
        vpath = static new RexPattern('/clienttmp/')
        processRequest(req, query)
        {
            /*
             *   look up the file - the key in our table is the ID string after
             *   the /clienttmp/ path prefix
             */
            local id = query[1].substr(12);
            local client = ClientSession.find(req);
            local desc = client.downloads[id];

            /* check for cancellation */
            if (query['cancel'] != nil)
            {
                /*
                 *   acknowledge the request - do this before we cancel the
                 *   file, since removing the file will remove the link that
                 *   fired this request (the order probably isn't a big deal
                 *   one way or the other, and we probably can't really control
                 *   it anyway as the browser might process the ack and the
                 *   event out of order, but just in case)
                 */
                sendAck(req);

                /* if we found the descriptor, cancel it */
                if (desc != nil)
                    client.cancelDownload(desc);

                /* done */
                return;
            }

            /* if we didn't find it, return failure */
            if (desc == nil)
            {
                req.sendReply(404);
                return;
            }

            /* open the file */
            local fp = nil;
            try
            {
                fp = File.openRawFile(desc.tempFileName, FileAccessRead);
            }
            catch (Exception exc)
            {
                /* couldn't send the file - send a 404 */
                req.sendReply(404);
                return;
            }

            /* set up the download file headers */
            local headers = [
                'Content-Disposition: attachment; filename=<<desc.resName>>'
                ];

            try
            {
                /* send the file's contents */
                req.sendReply(fp, desc.mimeType, 200, headers);
            }
            catch (Exception exc)
            {
                /* ignore errors */
            }

            /* done with the file */
            fp.closeFile();

            /*
             *   We've at least tried sending the file, so remove it from the
             *   download list.  This was just a temp file, so there's no need
             *   to keep it around even if we ran into an error sending it; if
             *   the send failed, the user can just repeat the operation that
             *   generated the file.
             */
            client.cancelDownload(desc);
        }

        /* add a file to our list of downloadable files */
        addFile(fileType, client)
        {
            /*
             *   Generate a server-side name template based on the file type.
             *   The name doesn't matter to us, but browsers will display it to
             *   the user, and many browsers use the server-side name as the
             *   default name for the newly downloaded file in the "Save File"
             *   dialog.  Many browsers also use the suffix to determine the
             *   file type, ignoring any Content-Type headers.
             */
            local tpl = [FileTypeLog -> 'Script#.txt',
                         FileTypeData -> 'File#.dat',
                         FileTypeCmd -> 'Command#.txt',
                         FileTypeText -> 'File#.txt',
                         FileTypeBin -> 'File#.bin',
                         FileTypeT3Image -> 'Story#.t3',
                         FileTypeT3Save -> 'Save#.t3v',
                         * -> 'File#'][fileType];

            /* figure the mime type based on the file type */
            local mimeType = [FileTypeLog -> 'text/plain',
                              FileTypeCmd -> 'text/plain',
                              FileTypeT3Image -> 'application/x-t3vm-image',
                              FileTypeT3Save -> 'application/x-t3vm-state',
                              * -> 'application/octet-stream'][fileType];

            /* replace the '#' in the template with the next ID value */
            tpl = tpl.findReplace('#', toString(nextID++));

            /* create a new table entry */
            local desc = new DownloadTempFile(tpl, mimeType);

            /* add this download to each client */
            client.addDownload(desc);

            /* return the descriptor */
            return desc;
        }

        /* next available ID */
        nextID = 1
    ;

    /*
     *   Downloadable temporary file descriptor.  We create this object when
     *   the program calls inputFile() to ask for a writable file.  This lets
     *   the caller create and write a temporary file on the server side; when
     *   the caller is done with the file, we'll offer the file for download to
     *   the client through the UI.
     */
    class DownloadTempFile: object
        construct(res, mimeType)
        {
            tempFileName = new TemporaryFile();
            resName = res;
            resPath = '/clienttmp/<<res>>';
            timeCreated = getTime(GetTimeTicks);
            self.mimeType = mimeType;
        }

        /*
         *   File spec interface.  This allows the DownloadTempFile to be used as
         *   though it were a filename string.
         *
         *   When the object is passed to one of the File.open methods, or to
         *   saveGame(), setScriptFile(), etc., the system will call our
         *   getFilename() method to determine the actual underlying file.
         *   We'll return our temporary file object.
         *
         *   When the underlying file is closed, the system calls our
         *   closeFile() method to notify us.
         */
        getFilename() { return tempFileName; }
        closeFile()
        {
            /* mark the file as ready for download */
            isReady = true;

            /* offering the file to the client as an HTTP download */
            webMainWin.offerDownload(self);
        }

        /* TemporaryFile object for the local temp file */
        tempFileName = nil

        /* root resource name, and full resource path */
        resName = nil
        resPath = nil

        /* MIME type */
        mimeType = nil

        /* creation timestamp, as a system tick count value */
        timeCreated = 0

        /* is the file ready for download? */
        isReady = nil

        /* this is a web temp file */
        isWebTempFile = true
    ;

    /* ------------------------------------------------------------------------ */
    /*
     *   Input event page.  The client javascript does a GET on this resource
     *   to send us an input event.
     */
    inputEventPage: WebResource
        vpath = '/webui/inputEvent'
        processRequest(req, query)
        {
            /* send the event to the main window object */
            webMainWin.receiveInputEvent(req, query);

            /* acknowledge the request */
            sendAck(req);
        }
    ;

    /* ------------------------------------------------------------------------ */
    /*
     *   Input dialog event page.  The web UI sends a GET to this page when the
     *   user selects a button in an input dialog.
     */
    inputDialogPage: WebResource
        vpath = '/webui/inputDialog'
        processRequest(req, query)
        {
            /* send the event to the main window */
            webMainWin.receiveInputDialog(req, query);

            /* acknowledge the request */
            sendAck(req);
        }
    ;

    /* ------------------------------------------------------------------------ */
    /*
     *   File dialog event page.  This page is used by the IFDB Storage Server
     *   file dialog to return information to the game UI.  The IFDB dialog
     *   page can't itself perform scripting actions on the enclosing dialog
     *   frame, since it's being served from a different domain - browsers
     *   prohibit cross-domain scripting for security reasons.  The IFDB dialog
     *   must therefore navigate back to a page within the game server domain
     *   in order to return information through scripting.  This is that page:
     *   when the IFDB page is ready to return information, it navigates its
     *   frame to this page, passing the return values in the request
     *   parameters.  Since this page is served by the game server, within the
     *   game server domain, the browser allows it to use scripting actions on
     *   its enclosing frame.  We finish the job by dismissing the dialog in
     *   the UI.
     */
    inputFilePage: WebResource
        vpath = '/webui/inputFile'
        processRequest(req, query)
        {
            /* set the file selection in the main window */
            webMainWin.receiveFileSelection(req, query);

            /*
             *   We have our response, so dismiss the file dialog.  Do this by
             *   sending back a page with script instructions to close the
             *   containing dialog window.
             */
            req.sendReply(
                '<html><body>'
                + '<script type="text/javascript">'
                + 'window.parent.dismissDialogById("inputFile");'
                + '</script>'
                + '</body></html>');
        }
    ;

    /*
     *   Cancel the input dialog.  This is called from the UI directly to
     *   cancel the file selection, when the user closes the dialog through the
     *   enclosing main page UI rather than from within the dialog.  This is
     *   useful if the dialog page fails to load, for example.
     *
     *   Note: the upload file dialog also uses this.  The upload dialog is
     *   basically a variation on the regular input file dialog.
     */
    inputFileCancel: WebResource
        vpath = '/webui/inputFileDismissed'
        processRequest(req, query)
        {
            /* note that the dialog has been dismissed */
            webMainWin.inputFileDismissed();

            /* acknowledge the request */
            sendAck(req);
        }
    ;

    /*
     *   Receive results from the input file dialog
     */
    uploadFilePage: WebResource
        vpath = '/webui/uploadFileDialog'
        processRequest(req, query)
        {
            /* send the request to the main window */
            webMainWin.receiveFileUpload(req, query);

            /* send back a script to dismiss the dialog */
            req.sendReply(
                '<html><body>'
                + '<script type="text/javascript">'
                + 'window.parent.dismissDialogById("uploadFile");'
                + '</script>'
                + '</body></html>');
        }
    ;

    /* ------------------------------------------------------------------------ */
    /*
     *   Receive the client's screen name setting
     */
    setScreenNamePage: WebResource
        vpath = '/webui/setScreenName'
        processRequest(req, query)
        {
            /* set the name in the client session */
            local cli = ClientSession.find(req);
            if (cli != nil)
                cli.screenName = query['name'];

            /* acknowledge the request */
            sendAck(req);
        }
    ;

Adv3Lite Library Reference Manual
Generated on 13/06/2025 from adv3Lite version 2.2.1