#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 25/04/2024 from adv3Lite version 2.0