webui.t | documentation |
#charset "us-ascii" /* * Copyright (c) 2010 Michael J. Roberts. All Rights Reserved. * * This module defines some useful helper functions for implementing a * TADS game with a Web-based user interface. */ #include <tads.h> #include <tadsnet.h> #include <httpsrv.h> #include <httpreq.h> #include <strbuf.h> #include <file.h> /* ------------------------------------------------------------------------ */ /* * write a message to the system debug log */ #define DbgMsg(msg) t3DebugTrace(T3DebugLog, msg) /* ------------------------------------------------------------------------ */ /* * Session timeout settings. Times are in milliseconds. */ /* * Housekeeping interval. We'll wait at least this long between * housekeeping passes. */ #define HousekeepingInterval 15000 /* * Session startup timeout. When we first start up, we'll give our first * client this long to establish a connection. If we don't hear anything * from a client within this interval, we'll assume that the client * exited before it had a chance to set up its first connection, so we'll * terminate the server. */ #define SessionStartupTimeout 90000 /* * Ongoing session timeout. After we've received our first connection, * we'll terminate the server if we go this long without any active * connections. */ #define SessionTimeout 120000 /* * Event request timeout. After an event request from a client has been * sitting in the queue for longer than this limit, we'll send a "keep * alive" reply to let the client know that we're still here. The client * will just turn around and send a new request. This periodic handshake * lets the client know we're still alive, and vice versa. */ #define EventRequestTimeout 90000 /* * Client session timeout. If we haven't seen a request from a given * client within this interval, we'll assume that the client has * disconnected, and we'll drop the client session state on this end. */ #define ClientSessionTimeout 60000 /* ------------------------------------------------------------------------ */ /* * Some handy Vector extensions */ modify Vector push(ele) { append(ele); } pop() { return getAndRemove(length()); } shift() { return getAndRemove(1); } getAndRemove(idx) { /* get the element */ local ret = self[idx]; /* remove it */ removeElementAt(idx); /* return the popped element */ return ret; } clear() { if (length() > 0) removeRange(1, length()); } ; /* ------------------------------------------------------------------------ */ /* * HTTPRequest extensions */ modify HTTPRequest /* * Send a reply, catching "socket disconnect" exceptions. In most * cases, server objects will want to use this method rather than the * native sendReply() so that they won't have to handle disconnect * exceptions manually. * * Disconnect exceptions are common with HTTP: the protocol is * stateless by design, so clients can close their sockets at any * time. Most modern browsers use HTTP 1.1, which allows the client * to maintain an open socket indefinitely and reuse it serially for * multiple requests, but browsers tend to close these reusable * sockets after a few minutes of inactivity. * * When this routine encounters a disconnected socket, it deletes the * client session record for the request, and then otherwise ignores * the error. This generally makes the client's socket management * transparent to the server, since if the client is still running * they'll just connect again with a new socket and retry any lost * requests. */ sendReplyCatch(reply, contType?, stat?, headers?) { /* catch any errors */ try { /* try sending the reply */ sendReply(reply, contType, stat, headers); } catch (SocketDisconnectException sde) { /* notify the associated client session of the disconnect */ local client = ClientSession.find(self); if (client != nil) client.checkDisconnect(); } } ; /* ------------------------------------------------------------------------ */ /* * Web UI Session object. This keeps track of miscellaneous items * associated with the game session. */ transient webSession: object /* * Get the full URL to the given resource. */ getFullUrl(resname) { return 'http://<<server.getAddress()>>:<< server.getPortNum()>><<resname>>'; } /* * Connect to the UI. By default, we ask the webMainWin object to * establish a connection, and we save the server object internally * for future reference. */ connectUI(srv) { /* connect the UI to the main window object */ webMainWin.connectUI(srv); /* save a reference to the server object */ server = srv; /* * set the zero point for the connection time - for timeout * purposes, the countdown starts now, since we obviously can't * have a connection before this point */ lastClientTime = getTime(GetTimeTicks); } /* * The session key. This identifies the server as a whole, and is * essentially an authentication mechanism that lets clients prove * they got our address from an authorized source (rather than just * stumbling across it via a port scan, say). Clients must hand this * to us on each request, either via a URL query parameter or via a * cookie. The normal setup (via WebResourceInit) is for the client * to send us the key as a URL parameter on the initial request, at * which point we'll pass it back as a set-cookie, removing the need * for the client to include the key in subsequent URLs. * * The key is just a random number that's long enough that an * interloper couldn't hope to guess it. We generate this on the * first evaluation, and it remains fixed at that point for as long * as we're running. */ sessionKey = (sessionKey = generateRandomKey()) /* * The collaborative session key. This is a secondary session key * that allows additional users to connect to the session for * collaborative play. */ collabKey = (collabKey = generateRandomKey()) /* * Validate a session key sent from the client */ validateKey(req, query) { /* get the key, either from the query string or from a cookie */ local rkey = query['TADS_session']; if (rkey == nil) rkey = req.getCookie('TADS_session'); /* if there's no key, it's an error */ if (rkey == nil) { /* no key -> bad request */ req.sendReply(400); return nil; } /* the key has to match either the main key or the collab key */ if (rkey != sessionKey && rkey != collabKey) { /* invalid key -> forbidden */ req.sendReply(403); return nil; } /* the session key is valid */ return true; } /* * The launcher's game ID. This is the ID passed from the web server * that launched the game, to let us know how the game is identified * in the launcher database. This is typically an IFDB TUID string. */ launcherGameID = nil /* * The launcher's user name. This is passed from the web server that * launched the game, to let us know the host user's screen name. We * use this as the user's default screen name in multi-user games. */ launcherUsername = 'Host' /* * The primary storage server session ID, for the user who launched * the server. If the user who launched the game logged in to a * cloud storage server, this is the session ID that we use to * transact business with the server on behalf of this logged-in * user. This token identifies and authenticates the user, but it's * ephemeral and it's only valid for the current game server session, * so it's not quite like a password. This is the session for the * launch user only; if other collaborative users join, they can get * their own session IDs that will allow them to store files under * their own private user folders on the server. */ storageSID = nil /* * Get the collaborative player launch URL. This is a URL that the * host can send to other players who wish to join the session as * collaborative users. */ getCollabUrl() { /* return the main window URL */ return webSession.getFullUrl( '<<webMainWin.vpath>>?TADS_session=<<collabKey>>'); } /* list of active client sessions (ClientSession objects) */ clientSessions = static new transient Vector() /* add a client session */ addClient(s) { clientSessions.append(s); lastClientTime = getTime(GetTimeTicks); everHadClient = true; } /* remove a client session */ removeClient(s) { clientSessions.removeElement(s); lastClientTime = getTime(GetTimeTicks); } /* the HTTPServer object running our web session */ server = nil /* * Run housekeeping tasks. The network event processor calls this * periodically to let us perform background cleanup tasks. Returns * the system tick time of the next housekeeping run. */ housekeeping() { /* * if it hasn't been long enough yet since the last housekeeping * run, skip this */ if (getTime(GetTimeTicks) < hkTime) return hkTime; /* send keep-alives and check for dead client sessions */ clientSessions.forEach(function(s) { /* send keep-alives to aged-out event requests */ s.sendKeepAlive(); /* check to see if the client has disconnected */ s.checkDisconnect(); }); /* if there are no client sessions, check for server termination */ if (clientSessions.length() == 0) { /* * There are no clients connected. If we've exceeded the * maximum interval for running without a client, shut down * the server. Use the initial connection time limit if * we've never had a client, otherwise use the ongoing idle * time limit. */ local limit = everHadClient ? SessionTimeout : SessionStartupTimeout; if (getTime(GetTimeTicks) > lastClientTime + limit) { /* no clients - shut down */ throw new QuittingException(); } } else { /* we have a session as of right now */ lastClientTime = getTime(GetTimeTicks); } /* update the housekeeping timer */ return hkTime = getTime(GetTimeTicks) + HousekeepingInterval; } /* system time (ms ticks) of next scheduled housekeeping pass */ hkTime = 0 /* the last time we noticed that we had a client connected */ lastClientTime = 0 /* have we ever had a client connection? */ everHadClient = nil ; /* * Generate a random key. This returns a 128-bit random number as a hex * string. This is designed for ephemeral identifiers, such as session * keys. */ generateRandomKey() { /* generate a long random hex string */ return rand('xxxxxxxx%-xxxx%-xxxx%-xxxxxxxxxxxx%-xxxx'); } /* ------------------------------------------------------------------------ */ /* * Client session. This represents a connection to one browser (or other * client application). Each browser client is a separate session, so we * create one instance of this class per connected browser. Note that * browser instances don't necessarily represent different users - a * single user could open multiple browser windows on the same server. * * We identify each browser instance via a session cookie, which we * establish when the client connects. The browser sends the cookie with * each subsequent request, allowing us to tie the request to the browser * session we previously set up. */ class ClientSession: object construct(skey, ssid) { /* remember my session key and storage server SID */ storageSID = ssid; /* note if we're a secondary (collaborative play) user */ isPrimary = (skey == webSession.sessionKey); /* add me to the master list of sessions */ webSession.addClient(self); /* note our last activity time */ updateEventTime(); /* create a UI preferences object */ uiPrefs = new WebUIPrefs(self); } /* the UI preferences object for this session */ uiPrefs = nil /* * The client's "screen name" - this is the user-visible name that * we'll show other users to identify commands and chat messages * entered by this client. */ screenName = '' /* set the default screen name for a client */ setDefaultScreenName() { /* * If this is the primary, the name is the launching user's * screen name. If not, it's 'Guest N', where N is the number of * guest connections we have. */ screenName = (isPrimary ? webSession.launcherUsername : 'Guest <<webSession.clientSessions.countWhich( {x: !x.isPrimary})>>'); } /* the client's IFDB user ID (a "TUID"), if logged in to IFDB */ ifdbTuid = nil /* the list of pending event requests from this client */ pendingReqs = perInstance(new Vector()) /* * The client's event queue. When a server-to-client event occurs, * we post it to each current client's queue. When the client sends * a get-event request, we satisfy it out of this queue. */ pendingEvts = perInstance(new Vector()) /* * The client session key. This identifies the client across * requests. We send this to the client as a cookie when they * connect, so we get it back on each request. */ clientKey = perInstance(generateRandomKey()) /* * The storage server session key for the user connected to this * session, if any. We can have multiple users logged in to the game * in collaborative play mode, each with their own separate storage * server session. This allows each user to have their own private * preference settings, saved games, etc. */ storageSID = nil /* * Am I the primary player? This is true if the player connected * using the primary session key. Collaborative players join through * the separate collaborative session key. */ isPrimary = nil /* * Is this session alive? When we detect that the client has * disconnected, we'll set this to nil. When waiting for a client in * a modal event loop, this can be used to terminate the wait if the * client disconnects. */ isAlive = true /* * Last request time, in system ticks (ms). We use this to determine * how long it's been since we've heard from the client, for timeout * purposes. This is updated any time we receive a command or event * request from the client, and each time we successfully send an * event reply. */ lastEventTime = 0 /* update the last event time for this client */ updateEventTime() { lastEventTime = getTime(GetTimeTicks); } /* class method: broadcast an event message to all connected clients */ broadcastEvent(msg) { /* send the event to each client in our active list */ webSession.clientSessions.forEach({ c: c.sendEvent(msg) }); } /* send an event to this client */ sendEvent(msg) { /* enqueue the event, then match it to a request if possible */ pendingEvts.push(msg); processQueues(); } /* receive an event request from the client */ requestEvent(req) { /* enqueue the request, then match it to an event if possible */ pendingReqs.push(new ClientEventRequest(req)); processQueues(); } /* flush outstanding events for this client */ flushEvents() { /* discard any queued events */ pendingEvts.clear(); /* send no-op replies to any pending event requests */ while (pendingReqs.length() > 0) { /* pull out the first request */ local req = pendingReqs.shift(); /* * Send a reply. Ignore any errors: the client probably * canceled their side of the socket already, so we'll * probably get a socket error sending the reply. Since * we're just canceling the request anyway, this is fine. */ try { /* send a no-op reply */ req.req.sendReply('<?xml version="1.0"?><noOp></noOp>', 'text/xml', 200); /* update the successful communications time */ updateEventTime(); } catch (Exception exc) { /* * ignore errors - the client probably canceled their * side of the socket already, so we'll probably fail * sending the reply */ } } } /* * Send a keep-alive reply to each pending request from this client * that's been waiting for longer than the timeout interval. * * Javascript clients in principle will wait indefinitely for an * XmlHttpRequest to complete, but in practice browsers tend to set * fairly long but finite time limits. If the time limit is exceeded * for a request, the client will fail the request with an error. To * prevent this, our main event loop (processNetRequests) * periodically calls this routine if no other events have occurred * recently. We'll clear out the pending event request queue for * each client by sending a no-op reply to each event. This tells * the client that the server is still alive and connected but has * nothing new to report. */ sendKeepAlive() { /* * Send no-op replies to any requests that have been in the queue * for longer than the maximum event wait interval. New requests * are added to the end of the queue, so the first item in the * queue is the oldest. */ local t = getTime(GetTimeTicks); while (pendingReqs.length() > 0 && t >= pendingReqs[1].reqTimeout) { /* pull out the oldest request */ local req = pendingReqs.shift(); try { /* send a no-op reply */ req.req.sendReply( '<?xml version="1.0"?><event><keepAlive/></event>', 'text/xml', 200, ['Cache-control: no-store, no-cache, ' + 'must-revalidate, post-check=0, pre-check=0', 'Pragma: no-cache', 'Expires: Mon, 26 Jul 1997 05:00:00 GMT']); /* successful communications, so note the last up time */ updateEventTime(); } catch (Exception exc) { /* couldn't send the reply - consider disconnecting */ checkDisconnect(); } } } /* broadcast a downloadable file to all clients */ broadcastDownload(desc) { /* add the download to all clients */ webSession.clientSessions.forEach({c: c.addDownload(desc)}); } /* add a download to this client */ addDownload(desc) { /* add the download to the table */ downloads[desc.resName] = desc; } /* * Cancel a downloadable file. Removes the file from the download * list and notifies the client that the file is no longer available. */ cancelDownload(desc) { /* remove the file from our table */ downloads.removeElement(desc.resName); /* tell the client that the file is no longer available */ webMainWin.sendWinEventTo( '<cancelDownload><<desc.resPath.htmlify()>></cancelDownload>', self); } /* this client's list of downloadable temporary files */ downloads = perInstance(new LookupTable()) /* get a list of all of my downloadable files */ allDownloads() { return downloads.valsToList(); } /* process the request and response queues */ processQueues() { /* keep going as long as we have requests and responses to pair up */ while (pendingReqs.length() > 0 && pendingEvts.length() > 0) { /* pull the first element out of each list */ local req = pendingReqs.shift(); local evt = pendingEvts[1]; /* * Answer the request with the event. Since this is an API * request masquerading as a page view, we need to generate a * live reply to each new request to the same virtual * resource path. So, include some headers to tell the * browser and any proxies not to cache the reply. */ try { /* send the reply */ req.req.sendReply( evt, 'text/xml', 200, ['Cache-control: no-store, no-cache, ' + 'must-revalidate, post-check=0, pre-check=0', 'Pragma: no-cache', 'Expires: Mon, 26 Jul 1997 05:00:00 GMT']); /* we've successfully sent the event - discard it */ pendingEvts.shift(); /* update the successful communications time */ updateEventTime(); } catch (SocketDisconnectException sde) { /* the socket has been disconnected - consider disconnecting */ checkDisconnect(); } catch (Exception exc) { /* * Ignore other errors, since we can just turn around and * send the event again in response to the next event * request from the client - no information will be lost, * since we still have the event in the queue. */ } } } /* wait for the queues to empty in preparation for shutting down */ shutdownWait(timeout) { /* process network requests until all clients disconnect */ processNetRequests({: webSession.clientSessions.length() == 0 }, timeout); } /* * Check to see if the client is still alive. If the client has no * pending event requests, and we haven't heard from the client in * more than the client session timeout interval, assume the client * is no longer connected and kill the session object. * * This should be called whenever a sending a reply to a request * fails with a Socket Disconnect exception. We also run this * periodically during routine housekeeping to check for clients that * haven't even bothered to send a request. */ checkDisconnect() { /* if we don't have any pending requests, kill the session */ if (pendingReqs.length() == 0 && getTime(GetTimeTicks) >= lastEventTime + ClientSessionTimeout) { /* remove the client session from our master list */ webSession.removeClient(self); /* the session is now dead */ isAlive = nil; } } /* * Class method: forcibly disconnect all clients. This simply * deletes the list of active clients and deletes any pending events * in their queues. This doesn't actually terminate their network * connections, but simply clears out any pending work for each * client that we've initiated on the server side. */ disconnectAll() { /* delete all pending events for each client */ webSession.clientSessions.forEach({ s: s.pendingReqs.clear() }); /* delete the client list */ webSession.clientSessions.clear(); } /* * Class method: Find a client session, given the session key or an * HTTPRequest object. */ find(key) { /* * if the key is given as an HTTPRequest, find the key by URL * parameter or cookie */ if (dataType(key) == TypeObject && key.ofKind(HTTPRequest)) { /* get the request */ local req = key; /* try the URL parameters and cookies */ if ((key = req.getQueryParam('TADS_client')) == nil && (key = req.getCookie('TADS_client')) == nil) { /* there's no client key, so there's no client */ return nil; } } /* find the client that matches the key string */ return webSession.clientSessions.valWhich({ x: x.clientKey == key }); } /* * Get the primary session. This is the session for the original * initiating user (the "host" in a multi-user game). */ getPrimary() { /* scan for a session with the host session key */ return webSession.clientSessions.valWhich({ x: x.isPrimary }); } ; /* ------------------------------------------------------------------------ */ /* * Client event request. Each client session object keeps a queue of * pending event requests, representing incoming "GET /webui/getEvent" * requests that have yet to be answered. */ class ClientEventRequest: object construct(req) { self.req = req; reqTimeout = getTime(GetTimeTicks) + EventRequestTimeout; } /* the underlying HTTPRequest object */ req = nil /* * The system time (ms ticks) when the request times out. If we * don't have an actual event to send in response by this time, the * housekeeper will generate a no-op reply just to let the client * know that we're still here. */ reqTimeout = 0 ; /* ------------------------------------------------------------------------ */ /* * A WebResource is a virtual file accessible via the HTTP server. Each * resource object has a path, which can be given as a simple string that * must be matched exactly, or as a RexPattern object with a regular * expression to be matched. Each object also has a "processRequest" * method, which the server invokes to answer the request when the path * is matched. */ class WebResource: object /* * The virtual path to the resource. This is the apparent URL path * to this resource, as seen by the client. * * URL paths follow the Unix file system conventions in terms of * format, but don't confuse the virtual path with an actual file * system path. The vpath doesn't have anything to do with the disk * file system on the server machine or anywhere else. That's why we * call it "virtual" - it's merely the apparent location, from the * client's perspective. * * When the server receives a request from the client, it looks at * the URL sent by the client to determine which WebResource object * should handle the request. The server does this by matching the * resource path portion of the URL to the virtual path of each * WebResource, until it finds a WebResource that matches. The * resource path in the URL is the part of the URL following the * domain, and continuing up to but not including any "?" query * parameters. The resource path always starts with a slash "/". * For example, for the URL "http://192.168.1.15/test/path?param=1", * the resource path would be "/test/path". * * The virtual path can be given as a string or as a RexPattern. If * it's a string, a URL resource path must match the virtual path * exactly, including upper/lower case. If the virtual path is given * as a RexPattern, the URL resource path will be matched to the * pattern with the usual regular expression rules. */ vpath = '' /* * Process the request. This is invoked when we determine that this * is the highest priority resource object matching the request. * 'req' is the HTTPRequest object; 'query' is the parsed query data * as returned by req.parseQuery(). The query information is * provided for convenience, in case the result depends on the query * parameters. */ processRequest(req, query) { /* by default, just send an empty HTML page */ req.sendReply('<html><title>TADS</title></html>', 'text/html', 200); } /* * The priority of this resource. If the path is given as a regular * expression, a given request might match more than one resource. * In such cases, the matching resource with the highest priority is * the one that's actually used to process the request. */ priority = 100 /* * The group this resource is part of. This is the object that * "contains" the resource, via its 'contents' property; any object * will work here, since it's just a place to put the contents list * for the resource group. * * By default, we put all resources into the mainWebGroup object. * * The point of the group is to allow different servers to use * different sets of resources, or to allow one server to use * different resource sets under different circumstances. When a * server processes a request, it does so by looking through the * 'contents' list for a group of its choice. */ group = mainWebGroup /* * Determine if this resource matches the given request. 'query' is * the parsed query from the request, as returned by * req.parseQuery(). 'req' is the HTTPRequest object representing * the request; you can use this to extract more information from the * request, such as cookies or the client's network address. * * This method returns true if the request matches this resource, nil * if not. * * You can override this to specify more complex matching rules than * you could achieve just by specifying the path string or * RexPattern. For example, you could make the request conditional * on the time of day, past request history, cookies in the request, * parameters, etc. */ matchRequest(query, req) { /* get the query path */ local qpath = query[1]; /* by default, we match GET */ local verb = req.getVerb().toUpper(); if (verb != 'GET' && verb != 'POST') return nil; /* if the virtual path a string, simply match the string exactly */ if (dataType(vpath) == TypeSString) return vpath == qpath; /* if it's a regular expression, match the pattern */ if (dataType(vpath) == TypeObject && vpath.ofKind(RexPattern)) return rexMatch(vpath, qpath) != nil; /* we can't match other path types */ return nil; } /* * Send a generic request acknowledgment or reply. This wraps the * given XML fragment in an XML document with the root type given by * the last element in our path name. If the 'xml' value is omitted, * we send "<ok/>" by default. */ sendAck(req, xml = '<ok/>') { /* * Figure the XML document root element. If we have a non-empty * path, use the last element of the path (as delimited by '/' * characters). Otherwise, use a default root of <reply>. */ local root = 'reply'; if (dataType(vpath) == TypeSString && vpath.length() > 0 && rexSearch(vpath, '/([^/]+)$') != nil) root = rexGroup(1)[3]; /* send the reply, wrapping the fragment in a proper XML document */ sendXML(req, root, xml); } /* * Send an XML reply. This wraps the given XML fragment in an XML * document with the given root element. */ sendXML(req, root, xml) { req.sendReply('<?xml version="1.0"?>\<<<root>>><<xml>></<<root>>>', 'text/xml', 200); } ; /* ------------------------------------------------------------------------ */ /* * A resource file request handler. This handles a request by sending * the contents of the resource file matching the given name. * * To expose a bundled game resource as a Web object that the client can * access and download via HTTP, simply create an instance of this class, * and set the virtual path (the vpath property) to the resource name. * See coverArtResource below for an example - that object creates a URL * for the Cover Art image so that the browser can download and display * it. * * You can expose *all* bundled resources in the entire game simply by * creating an object like this: * *. WebResourceResFile *. vpath = static new RexPattern('/') *. ; * * That creates a URL mapping that matches *any* URL path that * corresponds to a bundled resource name. The library intentionally * doesn't provide an object like this by default, as a security measure; * the default configuration as a rule tries to err on the side of * caution, and in this case the cautious thing to do is to hide * everything by default. There's really no system-level security risk * in exposing all resources, since the only files available as resources * are files you explicitly bundle into the build anyway; but even so, * some resources might be for internal use within the game, so we don't * want to just assume that everything should be downloadable. * * You can also expose resources on a directory-by-directory basis, * simply by specifying a longer path prefix: * *. WebResourceResFile *. vpath = static new RexPattern('/graphics/') *. ; * * Again, the library doesn't define anything like this by default, since * we don't want to impose any assumptions about how your resources are * organized. */ class WebResourceResFile: WebResource /* * Match a request. A resource file resource matches if we match the * virtual path setting for the resource, and the requested resource * file exists. */ matchRequest(query, req) { return inherited(query, req) && resExists(processName(query[1])); } /* process the request: send the resource file's contents */ processRequest(req, query) { /* get the local resource name */ local name = processName(query[1]); /* get the filename suffix (extension) */ local ext = nil; if (rexSearch('%.([^.]+)$', name) != nil) ext = rexGroup(1)[3]; local fp = nil; try { /* open the file in the appropriate mode */ if (isTextFile(name)) fp = File.openTextResource(name); else fp = File.openRawResource(name); } catch (FileException exc) { /* send a 404 error */ req.sendReply(404); return; } /* * If the file suffix implies a particular mime type, set it. * There are some media types that are significant to browsers, * but which the HTTPRequest object can't infer based on the * contents, so as a fallback infer the media type from the * filename suffix if possible. */ local mimeType = browserExtToMime[ext]; /* * Send the file's contents. Since resource files can be large * (e.g., images or audio files), send the reply asynchronously * so that we don't block other requests while the file is being * downloaded. Browsers typically download multimedia resources * in background threads so that the UI remains responsive during * large downloads, so we have to be prepared to handle these * overlapped requests while a download proceeds. */ req.sendReplyAsync(fp, mimeType); /* done with the file */ fp.closeFile(); } /* extension to MIME type map for important browser file types */ browserExtToMime = static [ 'css' -> 'text/css', 'js' -> 'text/javascript' ] /* * Process the name. This takes the path string from the query, and * returns the resource file name to look for. By default, we simply * return the same name specified by the client, minus the leading * '/' (since resource paths are always relative). */ processName(n) { return n.substr(2); } /* * Determine if the given file is a text file or a binary file. By * default, we base the determination solely on the filename suffix, * checking the extension against a list of common file types. */ isTextFile(fname) { /* get the extension */ if (rexMatch('.*%.([^.]+)$', fname) != nil) { /* pull out the extension */ local ext = rexGroup(1)[3].toLower(); /* * check against common binary types - if it's not there, * assume it's text */ return (binaryExts.indexOf(ext) == nil); } else { /* no extension - assume binary */ return nil; } } /* table of common binary file extensions */ binaryExts = ['jpg', 'jpeg', 'png', 'mng', 'bmp', 'gif', 'mpg', 'mp3', 'mid', 'ogg', 'wav', 'pdf', 'doc', 'docx', 'swf', 'dat', 'sav', 'bin', 'gam', 't3', 't3v']; ; /* * The resource handler for our standard library resources. All of the * library resources are in the /webuires resource folder. This exposes * everything in that folder as a downloadable Web object. */ webuiResources: WebResourceResFile vpath = static new RexPattern('/webuires/') ; /* the special cover art resource */ coverArtResource: WebResourceResFile vpath = static new RexPattern('/%.system/CoverArt%.(jpg|png)$') ; /* * Session initializer resource. This is a mix-in class designed to be * used for a special resource that initializes the session. Mix this * with a WebResource class to set up the initializer. When you connect * to the client via connectWebUI(), point the client to this resource. * * There are two elements to setting up the session. First, we need to * set the program session key as a cookie. The client obtains this from * the registration mechanism, whose purpose is to launch the game * program and send the connection information back to the client. The * client sends this to us in the form of a URL parameter, TADS_session. * This key is essentially for authentication, to make sure that the * client that we're talking to is actually the client that launched the * program: only that client would be able to get the key, because we * invent it and send it to the registrar, and the registrar only sends * it back to the client session it's already established on its end. * This prevents port scanners from finding our open port and trying to * crawl our "site" or otherwise access our services. * * The second setup element is to create a client session key. Whereas * the program session key is for our entire service, the client session * key is specific to this one connection. If the user opens two browser * windows on this server, each browser needs its own separate client * session so that we can tell the traffic apart. The client session key * is simply another random key we generate, and again we pass it back to * the client in a cookie. * * The reason we set cookies for both of these session keys is that it * lets the client pass the information back to us on subsequent requests * without having to encode another parameter in every URL. We set * session cookies in both cases; the program session is for * authentication purposes and so we don't want it to be stored or * shared, and the client session key is explicitly to identify this one * browser session, so it obviously shouldn't be shared across browser * instances or sessions. * * Note that instances should always provide a string (as opposed to a * regular expression) for the virtual path (the 'vpath' property). We * have to send the path to the browser UI as part of the connection * information, so we need a string we can send rather than a pattern to * match. */ class WebResourceInit: object /* * Connect to the client. The program should call this after * creating its HTTPServer object, which you pass here as 'srv'. * This establishes the client UI connection, generating the path to * the start page. */ connectUI(srv) { /* * point the client to our start page, adding the session key as * a query parameter */ connectWebUI(srv, '<<vpath>>?TADS_session=<<webSession.sessionKey>>'); } /* * Process the request. This sets up the program and client session * keys as cookies. */ processRequest(req, query) { /* get the session parameter from the query */ local skey = query['TADS_session']; /* set the session cookie in the reply */ if (skey) req.setCookie('TADS_session', '<<skey>>; path=/;'); /* check for a client session */ local ckey = req.getCookie('TADS_client'); if (ckey == nil || ClientSession.find(ckey) == nil) { /* * There's no client session ID cookie, or the ID is invalid * or has expired. Create a new client session. */ /* get the storage server session ID key from the request */ local ssid = query['storagesid']; /* get the user name */ local uname = query['username']; /* * if there's no storage server session, and they're * connecting under the primary server session key, use the * primary user's storage server session */ if (ssid == nil && skey == webSession.sessionKey) ssid = webSession.storageSID; /* create the new session object */ local client = new transient ClientSession(skey, ssid); /* if there's a user name, set it in the session */ if (uname != nil) client.screenName = uname; else client.setDefaultScreenName(); /* send the client session ID to the browser as a cookie */ req.setCookie('TADS_client', '<<client.clientKey>>; path=/;'); /* if this is a guest, alert everyone to the new connection */ if (!client.isPrimary) webMainWin.postSyntheticEvent('newGuest', client.screenName); } /* go return the underlying resource */ inherited(req, query); } /* the HTPTServer for communicating with the client */ server = nil ; /* ------------------------------------------------------------------------ */ /* * A WebResourceGroup is a container for WebResource objects. When a * server receives a request, it looks in its group list to find the * resource object that will handle the request. */ class WebResourceGroup: object /* * Should this group handle the given request? By default, we say * yes if the server that received the request is associated with * this group via the group's 'server' property. */ isGroupFor(req) { /* get the request's server object */ local srv = req.getServer(); /* * if this server matches our 'server' property, or is in the * list of servers if 'server' is a list, we're the group for the * request */ return (srv == server || (dataType(server) == TypeList && server.indexOf(srv) != nil)); } /* * The priority of the group, relative to other groups. If the same * server matches multiple groups, this allows you to designate which * group has precedence. A higher value means higher priority. */ priority = 100 /* * The HTTPServer object or objects this group is associated with. * The general event processor uses this to route a request to the * appropriate resource group, by finding the group that's associated * with the server that received the request. * * To associate a group with multiple servers, make this a list. */ server = nil /* the WebResource objects in the group */ contents = [] /* * Process a request. This looks for the highest priority matching * resource in the group, then hands the request to that resource for * processing. */ processRequest(req) { /* parse the query */ local query = req.parseQuery(); /* * Check for the session ID. The session ID is required either * in a URL parameter or in a cookie. If it's not present, * reject the request with a "403 Forbidden" error, since the * session is essentially an authentication token to tell us that * the client is in fact the same user that launched the game. */ if (!webSession.validateKey(req, query)) return; /* * Search our list for the first resource that matches this * request. The list is initialized in descending priority * order, so the first match we find will be the one with the * highest priority. */ local match = contents.valWhich({res: res.matchRequest(query, req)}); /* if we found a match, process it; otherwise return a 404 error */ if (match != nil) match.processRequest(req, query); else req.sendReply(404); } /* class property: list of all WebResourceGroup objects */ all = [] ; /* ------------------------------------------------------------------------ */ /* * The default web resource group. This is the default container for * WebResource objects. */ mainWebGroup: WebResourceGroup /* the default group matches any server, but with low priority */ isGroupFor(req) { return true; } priority = 1 ; /* ------------------------------------------------------------------------ */ /* * At startup, put each WebResource object into the contents list for its * group. */ PreinitObject execute() { /* build the contents list for each resource group */ forEachInstance(WebResource, function(obj) { /* get the group object for the resource */ local g = obj.group; /* if the group doesn't have a contents list yet, create one */ if (g.contents == nil) g.contents = []; /* add this resource to the contents list */ g.contents += obj; }); /* sort each group's contents list in priority order */ forEachInstance(WebResourceGroup, function(obj) { /* sort the group's contents list */ obj.contents = obj.contents.sort( SortDesc, { a, b: a.priority - b.priority }); /* add this group to the master list of groups */ WebResourceGroup.all += obj; }); /* sort the groups in descending order of priority */ WebResourceGroup.all = WebResourceGroup.all.sort( SortDesc, { a, b: a.priority - b.priority }); } ; /* ------------------------------------------------------------------------ */ /* * Guest connection request. This enables "switchboard" applications on * remote servers that keep track of multi-user game sessions, to show * users available sessions and connect new users to those sessions. * * The first step in setting up a switchboard is for the game server to * register itself with the switchboard by sending a request on startup. * That part is external to us - that's not handled within the game * program but rather within the web server script that launches the * game. Here, then, we simply assume that this work is already done. * * The second step is that the switchboard needs to check back with the * game server from time to time to see if it's still alive - essentially * a "ping" operation. We handle that here: if we respond to the * request, we're obviously still alive. * * The third step is that we need to send the switchboard a URL that lets * secondary users ("guests") connect to the game session. We handle * that here as well: our reply body is the client connection URL. */ guestConnectPage: WebResource vpath = '/webui/guestConnect' processRequest(req, query) { /* send the collaborative connection URL */ req.sendReply(webSession.getCollabUrl(), 'text/plain', 200); } ; /* ------------------------------------------------------------------------ */ /* * getEvent request. This is the mechanism we use to "send" events to * the client. The client sends a getEvent request to us, and we simply * put it in a queue - we don't send back any response immediately. As * soon as we want to send an event to the client, we go through the * queue of pending getEvent requests, and reply to each one with the * event we want to send. */ eventPage: WebResource vpath = '/webui/getEvent' processRequest(req, query) { /* find the client */ local c = ClientSession.find(req); /* if we found the client session object, send it the request */ if (c != nil) c.requestEvent(req); else req.sendReply(400); } /* broadcast an event message to each client */ sendEvent(msg) { /* build the full XML message */ msg = '<?xml version="1.0"?><event><<msg>></event>'; /* send it to each client */ ClientSession.broadcastEvent(msg); } /* send an event to a particular client */ sendEventTo(msg, client) { /* build the full XML message */ msg = '<?xml version="1.0"?><event><<msg>></event>'; /* send it to the given client */ client.sendEvent(msg); } ; /* * Flush events. This cancels any pending event requests for the client. * The client can use this after being reloaded to flush any outstanding * event requests from a past incarnation of the page. */ flushEventsPage: WebResource vpath = '/webui/flushEvents' processRequest(req, query) { /* find the client */ local c = ClientSession.find(req); /* if we found the client, send it the flush request */ if (c != nil) c.flushEvents(); /* * acknowledge the request; it's okay if we didn't find the * client session, since this just means there are no events to * flush for this client */ sendAck(req); } ; /* * getState request. The web page can send this to get a full accounting * of the current state of the UI. It does this automatically when first * loaded, and again when the user manually refreshes the page. * * We handle this by asking the main window to generate its state. */ uiStatePage: WebResource vpath = '/webui/getState' processRequest(req, query) { /* get the window making the request */ local w = webMainWin.winFromPath(query['window']); local client = ClientSession.find(req); /* if we found the window, send the reply */ if (w) { /* send the uiState reply for the window */ sendXML(req, 'uiState', w.getState(client)); } else { /* no window - send an error reply */ req.sendReply(406); } } ; /* ------------------------------------------------------------------------ */ /* * Process network requests. Continues until doneFunc() returns true, or * a timeout or error occurs. If we return because doneFunc() returned * true, we'll return nil. Otherwise, we'll return the NetEvent that * terminated the wait. */ processNetRequests(doneFunc, timeout?) { /* if there's a timeout, figure the ending time */ local endTime = (timeout != nil ? getTime(GetTimeTicks) + timeout : nil); /* keep going until the 'done' function returns true */ while (doneFunc == nil || !doneFunc()) { try { /* get the next housekeeping time */ local hkTime = webSession.hkTime; /* * figure the time to the next timeout - stop at the caller's * ending time, or the next housekeeping time, whichever is * sooner */ local tf = (endTime != nil && endTime < hkTime ? endTime : hkTime); /* figure the time remaining to the next timeout */ local dt = max(0, tf - getTime(GetTimeTicks)); /* * Flush any pending output. If we're waiting for user * input, this ensures that the last output is visible in the * UI while we await the next user action. */ flushOutput(); /* get the next network event */ local evt = getNetEvent(dt); /* see what we have */ switch (evt.evType) { case NetEvRequest: /* * This is an incoming network request from a client. * Check the protocol type of the request object. */ if (evt.evRequest.ofKind(HTTPRequest)) { /* * HTTP request - process it through the appropriate * web resource group. First, find the group that * wants to handle the request. */ local req = evt.evRequest; local group = WebResourceGroup.all.valWhich( { g: g.isGroupFor(req) }); /* * if there's a client associated with the request, * update its last activity time */ local client = ClientSession.find(req); if (client != nil) client.updateEventTime(); /* if we found a group, let it handle the request */ if (group != nil) { try { /* send the request to the group for processing */ group.processRequest(req); } catch (Exception exc) { /* * Unhandled exception - something went wrong * in the server, so the appropriate reply is * 500 Internal Server Error. Send the * exception message with the reply. */ local msg = 'Unhandled exception processing request: ' + exc.getExceptionMessage().specialsToText(); req.sendReply(msg, 'text/plain', 500); } } else { /* no group - send back a 404 error */ req.sendReply(404); } } break; case NetEvTimeout: /* * Timeout. Always try running housekeeping on a * timeout; the housekeeper will ignore this unless the * time has actually come. */ hkTime = webSession.housekeeping(); /* * Check for a caller timeout. If the caller's end time * has arrived, pass the timeout back to the caller as a * timeout event. */ if (endTime != nil && getTime(GetTimeTicks) >= endTime) return evt; /* * it wasn't a caller timeout, so it must have been an * internal housekeeping timeout, which we just handled; * simply continue looping */ break; case NetEvUIClose: /* * UI Closed. This tells us that the user has manually * closed the UI window. This only happens in the local * stand-alone configuration, where the "browser" is an * integrated part of the interpreter application, * simulating the traditional TADS interpreter setup * where the whole program is a single application * running on one machine. In this setup, closing the UI * window should dismiss the whole application, since * that's the convention on virtually all GUIs. */ /* disconnect all clients */ ClientSession.disconnectAll(); /* quit by signaling a "quit game" event */ throw new QuittingException(); } } catch (SocketDisconnectException sdx) { /* the client has closed its connection; ignore this */ } } /* indicate that the 'done' condition was triggered */ return nil; } /* ------------------------------------------------------------------------ */ /* * Web Window tracker. This is a game object that controls and remembers * the state of a "window" in the browser user interface. By "window", * we basically mean an HTML page, which might reside at the top level of * the browser itself, or inside an IFRAME element within an enclosing * page. * * Each WebWindow class corresponds to a particular HTML page that we * serve the client. The HTML page is the expression of the window in * the browser, and the WebWindow object is the expression of the same * information in the game program. The two are different facets of the * same conceptual UI object. The reason we need the two separate * expressions is that the server controls everything, but the client has * to do the actual display work, and the two parts of the program speak * different languages - the server is TADS, and the client is HTML. * * The WebWindow object on the server lets us easily reconstruct the UI * state in a newly opened browser window, or when the user performs a * page refresh. This object's job is to send information to the client * on demand that allows the client to display the page in its current * state. * * Note that a given WebWindow/HTML page combination can be used more * than once within the same UI. The pages defined in the library are * designed to be generic and reusable, so you might use the same window * class more than once for different purposes within the UI. The * library pages can also be subclassed, by subclassing the WebWindow * object and creating a customized copy of the corresponding HTML page * resource. */ class WebWindow: WebResourceResFile /* * The URL path to the window's HTML definition file, as seen by the * browser. For the pre-defined library window types, we expose the * HTML file in the root of the URL namespace - e.g., "/main.htm". * The files are actually stored in the /webuires folder, but we * expose them to the browser as though they were in the root folder * to make embedded object references on the pages simpler. The * browser figures the path to an embedded object relative to the * containing page, so by placing the containing page in the root * folder, embedded object paths don't have to worry about * referencing parent folders. */ vpath = nil /* * The window's actual source location, as a resource path. A given * WebWindow subclass corresponds to a particular HMTL page, since * the class and the page are facets of the same conceptual object * (one facet is the browser expression, the other is the game * program expression). */ src = nil /* process a request path referencing me into my actual resource path */ processName(n) { return src; } /* * Resolve a window path name. For container windows, this should * search the sub-windows for the given path. By default, we match * simply if the path matches our name. */ winFromPath(path) { return path == name ? self : nil; } /* * Flush the window. This sends any buffered text to the UI. */ flushWin() { } /* * Write text to the window. Subclasses with stream-oriented APIs * must override this. */ write(txt) { } /* * Clear the window. Subclasses must override this. */ clearWindow() { } /* * Get the window's current state. This returns a string containing * an XML fragment that describes the state of the window. This * information is sent to the HTML page when the browser asks for the * current layout state when first loaded or when the page is * refreshed. The XML format for each subclass is specific to the * Javascript on the class's HTML page. */ getState(client) { return ''; } /* send an event related to this window to all clients */ sendWinEvent(evt) { /* * send the event message, adding a <window> parameter to * identify the source */ eventPage.sendEvent('<window><<pathName>></window><<evt>>'); } /* send a window event to a specific client */ sendWinEventTo(evt, client) { eventPage.sendEventTo('<window><<pathName>></window><<evt>>', client); } /* specialsToHtml context */ sthCtx = perInstance(new SpecialsToHtmlState()) /* the name of this window */ name = nil /* the full path name of this window, in "win.sub.sub" format */ pathName = nil ; /* ------------------------------------------------------------------------ */ /* * Layout Window. This is a specialized Web Window tracker for our * layout page type, which is displayed using the resource file * webuires/layout.htm. This page is designed as a container of more * specialized sub-window pages; its job is to divide up the window space * into IFRAME elements that display the sub-windows, and to manage the * geometry of the IFRAMEs. * * The layout page is primarily designed to be the top-level page of the * web UI. The idea is to set up a layout page as the navigation URL for * the browser, so the layout page fills the browser window. You then * arrange your functional windows within the layout page - a command * window, a status line window, etc. This arrangement is similar to * banner window in HTML TADS, but IFRAMEs are considerably more * flexible; for example, they don't have to tile the main window, and * you can size them in the full range of units CSS provides. * * Layout windows aren't limited to the top level, though. Since you can * put any HTML page within an IFRAME, you can put another layout window * within an IFRAME, to further subdivide the space inside the IFRAME. */ class WebLayoutWindow: WebWindow /* * Resolve a window path name */ winFromPath(path) { /* get the first element and the rest of the path */ local idx = path.find('.'); if (idx == nil) idx = path.length() + 1; /* pull out the first element and the rest */ local head = path.substr(1, idx - 1); local tail = path.substr(idx + 1); /* if the first element doesn't match our name, it's not a match */ if (head != name) return nil; /* if that's the end of the path, we have our match */ if (tail == '') return self; /* match the rest of the path against our children */ foreach (local w in frames) { local match = w[1].winFromPath(tail); if (match != nil) return match; } /* no match */ return nil; } /* * Create a new window within the layout. This creates an IFRAME in * the browser, laid out according to the 'pos' argument, and * displays the given window object within the frame. * * If the window already exists, this updates the window with the new * layout settings. * * 'win' is a WebWindow object that will be displayed within the * IFRAME. This method automatically loads the HTML resource from * the WebWindow into the new IFRAME. * * 'name' is the name of the window. Each window within a layout * must have a distinct name. This allows you to refer to the * dimensions of other windows in 'pos' parameters. The name should * be alphanumeric. * * 'pos' is the layout position for the new frame. This is a string * in this format: 'left, top, width, height', where 'left' is the * horizontal position of the top left corner, 'top' is the vertical * position of the top left corner, 'width' is the width of the * window, and 'height' is the height. Each element can be specified * as a Javascript-style arithmetic expression. Within the * expression, you can use a mix of any of the following: * *. 123 - a number, representing a number of pixels on the display *. 5em - 5 'em' units, relative to the main BODY font in the window *. 5en - 5 'en' units in the main BODY font *. 5ex - 5 'ex' units in the main BODY font *. window.width - the width in pixels of the enclosing window *. window.height - the height in pixels of the enclosing window *. 50% - percentage of the width or height of the enclosing window *. content.width - the width in pixels of the contents of the frame *. content.height - the height in pixels of the contents of the frame *. x.left - horizontal coordinate of leftmost edge of window 'x' *. x.right - horizontal coordinate of rightmost edge of window 'x' *. x.top - vertical coordinate of top edge of window 'x' *. x.bottom - vertical coordinate of bottom edge of window 'x' *. x.width - width in pixels of window 'x' *. x.height - height in pixels of window 'x' * * The "window" dimensions refer to the *enclosing* window. If this * layout window is the main page of the UI, this is simply the * browser window itself. For a layout window nested within another * frame, this is the enclosing frame. * * Percentage units apply to the enclosing window. When a percentage * is used in the 'left' or 'width' slot, it applies to the width of * the enclosing window; in the 'top' or 'height' slot, it applies to * the height. * * The "content" dimensions refer to the contents of the frame we're * creating. This is the size of the contents as actually laid out * in the browser. * * "x.left" and so on refer to the dimensions of other frames *within * this same layout window*. 'x' is the name of another window * within the same layout, as specified by the 'name' argument given * when the window was created. */ createFrame(win, name, pos) { /* set the window's internal name to its full path name */ win.name = name; win.pathName = self.name + '.' + name; /* add the window to our list */ frames[name] = [win, pos]; /* notify the UI */ sendWinEvent('<subwin>' + '<name><<name>></name>' + '<pos><<pos>></pos>' + '<src><<win.vpath.htmlify()>></src>' + '</subwin>'); } /* * Flush this window. For a layout window, we simply flush each * child window. */ flushWin() { /* flush each child window */ frames.forEach({w: w[1].flushWin()}); } /* * Get the state. */ getState(client) { /* build an XML fragment describing the list of frames */ local s = ''; frames.forEachAssoc(function(name, info) { s += '<subwin><name><<name>></name>' + '<pos><<info[2]>></pos>' + '<src><<info[1].vpath.htmlify()>></src>' + '</subwin>'; }); /* return the completed state object */ return s; } /* * The table of active frames within this layout. This table is * keyed by window name; each entry is a list of [win, pos], where * 'win' is the WebWindow object for the window, and 'pos' is its * position parameter. */ frames = perInstance(new LookupTable(16, 32)) /* my virtual path and the actual resource file location */ vpath = '/layoutwin.htm' src = 'webuires/layoutwin.htm' ; /* ------------------------------------------------------------------------ */ /* * Command Window. This object keeps track of the state of command * window within the web UI. */ class WebCommandWin: WebWindow /* * Write to the window */ write(txt) { /* add the text to the output buffer */ outbuf.append(txt); } /* * Flush the buffers */ flushWin() { /* get the current output buffer */ local txt = toString(outbuf).specialsToHtml(sthCtx); /* add it to the state buffer (text since last input) */ textbuf.append(txt); /* send the text to the client via an event */ sendWinEvent('<say><<txt.htmlify()>></say>'); /* we've now processed the pending output buffer */ outbuf.deleteChars(1); } /* * Read a line of input in this window. Blocks until the reply is * received. Returns nil on timeout. */ getInputLine(timeout?) { /* flush buffered output */ flushWin(); /* clear out the last input */ lastInput = nil; lastInputClient = nil; lastInputReady = nil; /* set the UI state */ mode = 'inputLine'; isInputOpen = true; /* send the inputLine event to the client */ sendWinEvent('<inputLine/>'); /* process network events until we get our input or we time out */ processNetRequests( {: lastInputReady || webMainWin.syntheticEventReady() }, timeout); /* move the current textbuf contents to the scrollback list */ textbufToScrollback(lastInput); /* back to 'working' mode */ mode = 'working'; /* check the result */ if (lastInput != nil) { /* we got a reply - mark the input line as closed in the UI */ isInputOpen = nil; /* reset to the start of the line in the output context */ sthCtx.resetLine(); /* remember the source of the command */ webMainWin.curCmdClient = lastInputClient; /* return the Line Input event */ return [InEvtLine, lastInput]; } else if (webMainWin.syntheticEventReady()) { /* there's a synthetic event available - return it */ return webMainWin.getSyntheticEvent(); } else { /* we didn't get a reply, so we timed out */ return [InEvtTimeout, '']; } } /* * Cancel an input line that was interrupted by a timeout */ cancelInputLine(reset) { /* if the input line is open, send the cancel event */ if (isInputOpen) { /* send the cancel event to the client */ sendWinEvent('<cancelInputLine reset="<< reset ? 'yes' : 'no'>>" />'); /* the input line is closed */ isInputOpen = nil; /* reset to the start of the line in the output context */ sthCtx.resetLine(); } } /* * Get the state of this command window */ getState(client) { return '<mode><<mode>></mode><scrollback>' + scrollback.join() + '<sbitem><text><<toString(textbuf).htmlify()>></text></sbitem>' + '</scrollback>'; } /* * Receive input from the client */ receiveInput(req, query) { /* remember the text */ lastInput = query['txt']; /* remember the source of the input */ lastInputClient = ClientSession.find(req); /* set the input-ready flag so we exit the modal input loop */ lastInputReady = true; /* get the user who entered the command */ local user = (lastInputClient != nil ? lastInputClient.screenName : ''); /* tell any other windows listening in about the new input */ sendWinEvent('<closeInputLine>' + (lastInput != nil ? '<text><<lastInput.htmlify()>></text>' : '') + '<user><<user.htmlify()>></user>' + '</closeInputLine>'); /* reset to the start of the line in the output context */ sthCtx.resetLine(); } /* * Clear the window */ clearWindow() { /* flush the output buffer */ flushWin(); /* * clear the transcript - if the user refreshes the browser * window, we want to show a blank window */ textbuf.deleteChars(1); scrollback.clear(); /* send a clear window event */ sendWinEvent('<clearWindow/>'); /* reset to the start of the line in the output context */ sthCtx.resetLine(); } /* * Move the current text buffer contents to the scrollback list. If * this would make the scrollback list exceed the limit, we'll drop * the oldest item. * * 'cmd' is the command line text of the last input. We include this * in the srollback list with special tagging so that the UI can * display it in a custom style, if it wants. */ textbufToScrollback(cmd) { /* get the current buffer, tagged as a <text> entry in the list */ local t = '<text><<toString(textbuf).htmlify()>></text>'; /* add the command line tagged as <input> */ if (cmd != nil) t += '<input><<cmd.htmlify()>></input>'; /* add the buffer to the scrollback list */ scrollback.append('<sbitem><<t>></sbitem>'); /* if this pushes us past the limit, drop the oldest item */ if (scrollback.length() > scrollbackLimit) scrollback.shift(); /* clear text buffer */ textbuf.deleteChars(1); } /* * Show a "More" prompt */ showMorePrompt() { /* flush the output buffer */ flushWin(); /* send a "More" prompt event to the UI */ sendWinEvent('<morePrompt/>'); mode = 'morePrompt'; moreMode = true; /* process events until More mode is done */ processNetRequests({: !moreMode }); /* return the default mode */ mode = 'working'; } /* * receive notification from the client that the user has responded * to the More prompt, ending the pause */ endMoreMode() { /* no longer in More mode */ moreMode = nil; } /* main window text buffer since last input read */ textbuf = perInstance(new StringBuffer(4096)) /* * Scrollback list. After each input, we add the contents of * 'textbuf' to this list. If this pushes the list past the limit, * we drop the oldest item. This is used to reconstruct a reasonable * amount of scrollback history when a new client connects, or when * an existing client refreshes the page. */ scrollback = perInstance(new Vector()) /* * The scrollback limit, as a number of command inputs. Each input * interaction adds one item to the scrollback list. When the number * of items in the list exceeds the limit set here, we drop the * oldest item. */ scrollbackLimit = 10 /* pending output buffer, since last flush */ outbuf = perInstance(new StringBuffer(4096)) /* the text of the last input line we received from the client */ lastInput = nil /* is input ready? */ lastInputReady = nil /* client session who sent the last input line */ lastInputClient = nil /* * Is an input line open? This is true between sending an * <inputLine> event and either getting a reply, or explicitly * sending a close or cancel event. */ isInputOpen = nil /* flag: we're in More mode */ moreMode = nil /* * Current UI mode. This is 'working' if the program is running and * in the process of computing and/or generating output; 'inputLine' * if we're waiting for the user to enter a line of input; * 'morePrompt' if we're showing a "More" prompt. */ mode = 'working' /* my virtual path, and the actual resource file location */ vpath = '/cmdwin.htm' src = 'webuires/cmdwin.htm' ; /* * input-line event page */ inputLinePage: WebResource vpath = '/webui/inputLine' processRequest(req, query) { /* find the window */ local w = webMainWin.winFromPath(query['window']); /* dispatch to the window if we found it */ if (w != nil) { /* send the input to the window */ w.receiveInput(req, query); /* acknowledge the request */ sendAck(req); } else req.sendReply(406); } ; /* * "More" prompt done event page */ morePromptDonePage: WebResource vpath = '/webui/morePromptDone' processRequest(req, query) { /* find the target winodw */ local w = webMainWin.winFromPath(query['window']); if (w != nil) { /* release More mode in the server window */ w.endMoreMode(); /* acknowledge the request */ sendAck(req); } else req.sendReply(406); } ; /* ------------------------------------------------------------------------ */ /* * Set Preferences command */ setPrefsPage: WebResource vpath = '/webui/setPrefs' processRequest(req, query) { /* get the request body */ local f = req.getBody(); if (f == nil) { errorReply(req, 'Error saving preferences: no data received'); return; } else if (f == 'overflow') { errorReply(req, 'Error saving preferences: message too large'); return; } /* get the client session for the request */ local cli = ClientSession.find(req); if (cli == nil) { errorReply(req, 'Error saving preferences: missing session'); return; } /* process the file through the profile reader */ cli.uiPrefs.readSettings(f); /* save the updated settings */ cli.uiPrefs.saveSettings(); /* done with the file */ f.closeFile(); /* done - acknowledge the request */ sendAck(req); } /* send an error as the reply to a request, formatted into XML */ errorReply(req, msg) { sendXML(req, 'reply', '<error><<msg.htmlify()>></error>'); } ; /* * UI Settings list. This represents a named UI settings profile in the * Web UI. A profile is a list of name/value pairs. * * Most of the name keys are style IDs defined in the javascript for on * the UI side - see main.js. These style IDs are arbitrary keys we * define to identify UI elements - "mainFont" for the main font name, * "statusBkg" for the status-line window's background color, etc. Each * style ID generally corresponds to a dialog control widget in the * preferences dialog in the javascript UI, and also corresponds to one * or more CSS style selectors. The mapping from style ID to CSS is * defined in the UI javascript (see prefsMapper in main.js). * * The non-style key "profileName" is the user-visible name of this * profile. Internally, we refer to profiles using ID values, which are * arbitrary identifiers generated by the UI when it creates a new * profile (it currently uses integer keys). */ class WebUIProfile: object construct(id) { self.profileID = id; self.settings = new LookupTable(); } /* set a preference item in the profile */ setItem(id, val) { settings[id] = val; } /* call a callback for each style: func(id, val) */ forEach(func) { settings.forEachAssoc(func); } /* internal ID of the profile */ profileID = '' /* table of style value strings, keyed by style ID */ settings = nil ; /* * Web UI preferences. This object contains the in-memory version of the * display style preferences file. * * Each client session has its own copy of this object, because each * client can be associated with a different user, and each user has * their own preferences file. */ class WebUIPrefs: object construct(c) { /* remember our client session object */ clientSession = c; /* load the initial settings from the user's config file */ loadSettings(); } /* read the settings file */ loadSettings() { /* open the preferences file; do nothing if that fails */ local f = openSettingsFile(FileAccessRead); if (f == nil) return; /* read the settings from the file */ readSettings(f); /* done with the file */ f.closeFile(); } /* read settings from a file */ readSettings(f) { /* set up our table of profiles */ local pros = profileTab = new LookupTable(); /* we don't have a current profile selection yet */ curProfile = nil; /* read the file */ for (;;) { /* read the next line */ local l = f.readFile(); if (l == nil) break; /* if it's a valid line, process it */ if (rexMatch(curProPat, l) != nil) { /* current profile setting */ curProfile = rexGroup(1)[3]; } else if (rexMatch(proItemPat, l) != nil) { /* style item definition - pull out the parts */ local proid = rexGroup(1)[3]; local key = rexGroup(2)[3]; local val = rexGroup(3)[3]; /* if the profile isn't in the table yet, add it */ local pro = pros[proid]; if (pro == nil) pros[proid] = pro = new WebUIProfile(proid); /* add this item to the table */ pro.setItem(key, val); } } } /* current profile ID pattern - current-profile:xxx */ curProPat = static new RexPattern('current-profile=([^\n]*)\n?$') /* setting ID pattern for profile items - nnn.xxx=yyy */ proItemPat = static new RexPattern('([^.]+)%.([^=]+)=([^\n]*)\n?$') /* save the current settings to the user's config file */ saveSettings() { /* if there's no profile table, there's nothing to write */ if (profileTab == nil) return; try { /* open the preferences file; do nothing if that fails */ local f = openSettingsFile(FileAccessWrite); if (f == nil) return; /* write the current profile ID */ f.writeFile('current-profile=<<curProfile>>\n'); /* write the profiles */ profileTab.forEachAssoc(function(id, pro) { /* write each element of this profile */ pro.forEach(function(key, val) { /* write this profile item */ f.writeFile('<<id>>.<<key>>=<<val>>\n'); }); }); /* done with the file */ f.closeFile(); } catch (Exception e) { /* * couldn't save the file; this isn't fatal, so just log an * error event */ webMainWin.postSyntheticEvent( 'logError', 'An error occurred saving your setting changes. (Details: <<e.getExceptionMessage()>>)'); } } /* open the settings file */ openSettingsFile(access) { /* get the filename; abort if we don't have a file */ local name = getSettingsFile(); if (name == nil) return nil; /* open the file */ try { /* open the file and return the handle */ return File.openTextFile(name, access, 'ascii'); } catch (Exception exc) { /* failed to open the file */ return nil; } } /* get the settings file path */ getSettingsFile() { /* if we're in local stand-alone mode, use the local Web UI file */ if (getLaunchHostAddr() == nil) return WebUIPrefsFile; /* if there's a storage server session, the file is on the server */ if (clientSession.storageSID != nil) return '~<<clientSession.storageSID>>/special/2'; /* * We're in client/server mode, but there's no storage server. * In this mode, we don't have any server-side location to store * files, so we can't save or restore the configuration. */ return nil; } /* get the current settings as XML, to send to the web UI */ getXML() { /* if there's no profile table, there are no settings to return */ if (profileTab == nil) return ''; /* create a buffer for the results */ local s = new StringBuffer(); /* add the current profile */ s.append('<currentProfile><<curProfile>></currentProfile>'); /* add each profile's contents */ profileTab.forEachAssoc(function(id, pro) { /* open this profile section */ s.append('<profile><id><<id>></id>'); /* add each style element */ pro.forEach(function(id, val) { s.append('<item>' + '<id><<id.htmlify()>></id>' + '<value><<val.htmlify()>></value>' + '</item>'); }); /* close this profile's section */ s.append('</profile>'); }); /* return the result as an XML string */ return toString(s); } /* the client session for this preference list */ clientSession = nil /* * profile table - this is a LookupTable of WebUIProfile objects * keyed by profile name */ profileTab = nil /* current active profile selected by the user */ curProfile = nil ; /* ------------------------------------------------------------------------ */ /* * Status line window */ class WebStatusWin: WebWindow /* my request path and actual resource path */ vpath = '/statwin.htm' src = 'webuires/statwin.htm' /* * Set the room and score/turns portions of the status line. This * sets the left side of the status line to the 'room' text (which * can contain HTML markups), and the right side to the the * score/turns values, if present. If the turn counter is omitted * but the score value is present, we'll just show the score value; * otherwise we'll format these as "score/turns". If no score value * is present, we'll leave the right side blank. */ setStatus(room, score?, turns?) { /* set up the room text in the left portion */ local msg = '<div class="statusleft"><<room>></div>'; /* * format the right side: 'score/turns', 'score', or empty, * depending on what the caller specified */ local rt = (score != nil ? toString(score) : '') + (turns != nil ? '/' + turns : ''); /* if there's a right side, wrap it with right alignment */ if (rt != '') msg += '<div class="statusright"><<rt>></div>'; /* * our left/right divisions are floats, which some browsers don't * count against the container size; so add a clear:all division * to make sure we count the height properly */ msg += '<div style="clear: both;"></div>'; /* set the text */ setStatusText(msg); } /* * Set the text of the status line. This sets the entire status * window to the given HTML text, without any additional formatting. */ setStatusText(msg) { /* setting new text, so reset the stream context */ sthCtx.resetState(); /* set the new text */ txt_.deleteChars(1); txt_.append(msg.specialsToHtml(sthCtx)); /* note that we have new text to send to the UI */ deltas_ = true; } /* add text to the status line */ write(msg) { /* add the text */ txt_.append(msg.specialsToHtml(sthCtx)); /* note that we have new text to send to the UI */ deltas_ = true; } /* clear the window */ clearWindow() { setStatusText(''); } /* flush pending text to the window */ flushWin() { /* if we have any deltas since the last flush, send changes */ if (deltas_) { /* * Send a set-text event, along with a resize. The resize * ensures that the status line adjusts to the current * content size, assuming the window is using automatic * content-height sizing. */ sendWinEvent( '<text><<toString(txt_).htmlify()>></text><resize/>'); /* reset the deltas counter */ deltas_ = nil; } } /* * Refigure the window size. The status line is generally set up to * be automatically sized to its contents, which requires that we * tell the UI when it's time to recalculate the layout to reflect * the current contents after a change. */ resize() { sendWinEvent('<resize/>'); } /* get the current state to send to the browser */ getState(client) { return '<text><<toString(txt_).htmlify()>></text>'; } /* do we have any deltas since the last flush? */ deltas_ = nil /* the current status message */ txt_ = perInstance(new StringBuffer(512)) ; /* ------------------------------------------------------------------------ */ /* * The standard "main window" of our user interface. This is the game * object that represents the default initial HTML page that the player's * web browser connects to. We build this out of three base classes: * * - WebResourceInit, because this is the starting page that the browser * initially connects to. This class does the initial handshaking to set * up the session. * * - WebResourceResFile, because we store the HTML for the page in a * resource file. This class does the work of sending the resource * file's contents to the browser. * * - WebLayoutWindow, because the default main page is also a layout * window, which is basically a container for IFRAME elements where we * plug in the sub-windows that make up the game's user interface. * * Games can customize the front page in any way they like. If you want * to customize the HTML of the main page, you can substitute a different * HTML (.htm) file, and change the processName() method to return the * name of that file. If you want to use something other than a layout * window as the front page, you can simply replace this whole class. */ transient webMainWin: WebResourceInit, WebLayoutWindow, WebResourceResFile /* * match the webuires directory path as the URL path, but map this to * main.htm as the underlying resource name */ vpath = '/' processName(n) { return 'webuires/main.htm'; } /* the top window is always called "main" */ name = 'main' pathName = 'main' /* the window title */ title = 'TADS' /* set the window title */ setTitle(title) { /* remember the title */ self.title = title; /* update the browser */ sendWinEvent('<setTitle><<title.htmlify()>></setTitle>'); } /* * Client session for current command line input. Certain modal * interactions, such as file dialogs, are directed only to the * client that initiated the current command. */ curCmdClient = nil /* get the state */ getState(client) { /* get the inherited layout state */ local s = inherited(client); /* add the preference settings */ s += '<prefs><<client.uiPrefs.getXML()>></prefs>'; /* add the window title */ s += '<title><<title.htmlify()>></title>'; /* add the input event, input file, and input dialog states */ s += inputEventState; s += inputDialogState; s += menuSysState; /* if this is the command client, include the file dialog state */ if (client == curCmdClient) s += fileDialogState; /* add the downloadable file list */ s += client.allDownloads().mapAll( { x: x.isReady ? '<offerDownload><<x.resPath.htmlify()>></offerDownload>' : '' } ).join(); /* return the result */ return s; } /* wait for an input event */ getInputEvent(timeout) { /* flush buffered output */ flushWin(); /* send the get-event request to the client */ inputEventState = '<getInputEvent/>'; inputEventResult = nil; sendWinEvent(inputEventState); /* process network events until we get an input event */ processNetRequests({: inputEventResult != nil }, timeout); /* check what happened */ if (inputEventResult) { /* we got an input event - return it */ return inputEventResult; } else { /* * There's no result, so we timed out. Tell the UI to cancel * the input event mode. */ inputEventState = ''; sendWinEvent('<cancelInputEvent/>'); /* return a timeout event */ return [InEvtTimeout]; } } /* receive an input event */ receiveInputEvent(req, query) { local typ = query['type']; local param = query['param']; switch (typ) { case 'key': inputEventResult = [InEvtKey, param]; break; case 'href': inputEventResult = [InEvtHref, param]; break; default: inputEventResult = [InEvtEof]; break; } } /* show the file selector dialog */ getInputFile(prompt, dialogType, fileType, flags) { /* mappings from FileTypeXxx to storage manager type codes */ local typeMap = [ FileTypeLog -> 'log', FileTypeData -> 'dat', FileTypeCmd -> 'cmd', FileTypeText -> 'txt', FileTypeBin -> 'bin', FileTypeT3Image -> 't3', FileTypeT3Save -> 't3v', * -> '*']; /* get the storage server session for the command client */ local sid = curCmdClient.storageSID; /* * If we have a session, get the URL to the storage server file * selection dialog. Note that we don't specify the session in * the URL, since the storage server separately maintains the * session with the client via a cookie. */ local url = nil; if (sid != nil) url = getNetStorageURL( 'fileDialog?filetype=<<typeMap[fileType]>>' + '&dlgtype=<<dialogType == InFileOpen ? 'open' : 'save'>>' + '&prompt=<<prompt.urlEncode()>>' + '&ret=<<webSession.getFullUrl('/webui/inputFile')>>'); /* * if there's no storage server, try using a client PC upload or * download */ if (url == nil) return getInputFileFromClient(prompt, dialogType, fileType, flags); /* * set up the file dialog state description (store it in a * property, so that we can send it again as part of a state * request if the client window is reloaded from scratch) */ fileDialogState = '<fileDialog>' + '<prompt><<prompt.htmlify()>></prompt>' + '<dialogType><<dialogType == InFileOpen ? 'open' : 'save'>></dialogType>' + '<fileType><<typeMap[fileType]>></fileType>' + '<url><<url.htmlify()>></url>' + '</fileDialog>'; /* * Presume that the dialog will be canceled. If the user clicks * the close box on the dialog, the dialog will be dismissed * without ever sending us a result. This counts as a * cancellation, so it's the default result. */ fileDialogResult = [InFileCancel]; /* send the request to the current command client */ sendWinEventTo(fileDialogState, curCmdClient); /* process network events until the dialog is dismissed */ processNetRequests( {: fileDialogState == '' || !curCmdClient.isAlive }); /* return the dialog result */ return fileDialogResult; } /* * Get an input file from the client PC. We'll attempt to upload or * download a file from/to the client PC, using a local temporary * file for the actual file operations. This is a special form of * the input file dialog that we use when we're not connected to a * storage server. */ getInputFileFromClient(prompt, dialogType, fileType, flags) { /* use the appropriate procedure for Open vs Save */ if (dialogType == InFileOpen) { /* * Opening an existing file. Show a dialog asking the user * to select a file to upload. If the user uploads a file, * we'll use the local temp file created by the upload as the * result of the input dialog. */ fileDialogState = '<uploadFileDialog>' + '<prompt><<prompt.htmlify()>></prompt>' + '</uploadFileDialog>'; /* presume the user will cancel the dialog */ fileDialogResult = [InFileCancel]; /* send the request to the UI */ sendWinEventTo(fileDialogState, curCmdClient); /* process network events until the dialog is dismissed */ processNetRequests( {: fileDialogState == '' || !curCmdClient.isAlive }); /* return the file dialog result */ return fileDialogResult; } else if (dialogType == InFileSave) { /* * Saving to a new file. The caller is asking the user to * choose a name for a file that the caller *intends* to * create, but hasn't yet created. HTTP can only do a * download as a transaction, though - we can offer an * existing file to the user to download, and the user will * choose the location for the download as part of that * transaction. We don't have that file yet because the * standard protocol is to ask the user for the name first, * then write the selected file. * * So, we have to play a little game. We don't ask the user * to save anything right now, because there's no file to * offer for download yet. Instead, we silently generate a * local temporary file name and return that to our caller. * Our caller will create the temp file and write its data. * * The trick is that we wrap this temp file name in a * DownloadTempFile object. When the caller finishes * writing, it will call closeFile() on the temp file. The * system will pass that along to use by calling closeFile() * on our DownloadTempFile object. At that point, we'll have * a completed temporary file, so we'll pop up a download box * at that point offering the file for download to the user. * The user can then select a local file on the client side, * and we'll send the temporary file's contents as the * download to store in the client file. */ return [InFileSuccess, tempFileDownloadPage.addFile(fileType, curCmdClient)]; } else { /* unknown dialog type */ return [InFileFailure]; } } /* * Offer a file for download to the client. 'file' is a * DownloadTempFile object previously created by a call to * inputFile(). */ offerDownload(file) { /* * send the "offer download" event to the client - the client * will display an iframe with the file as an "attachment", which * will trigger the browser to offer it as a download */ sendWinEventTo( '<offerDownload><<file.resPath.htmlify()>></offerDownload>', curCmdClient); } /* receive a file selection from the file selector dialog */ receiveFileSelection(req, query) { /* get the filename from the query */ local f = query['file']; local desc = query['desc']; local cancel = (query['cancel'] != nil); local err = query['error']; /* * Set the file dialog result list. This uses the same format as * the native inputFile() routine: the first element is the * status code (Success, Failure, Cancel); on success, the second * element is the filename string. We add two bits of */ if (err != nil) fileDialogResult = [InFileFailure, err]; else if (cancel || f == nil || f == '') fileDialogResult = [InFileCancel]; else { /* we got a filename - add the SID prefix if necessary */ if (rexMatch('~[^/]+/', f) == nil) f = '~<<curCmdClient.storageSID>>/<<f>>'; /* success - return the filename */ fileDialogResult = [InFileSuccess, f]; /* if there's a description string, return that as well */ if (desc != nil) fileDialogResult += desc; } } /* receive notification that the file dialog has been closed */ inputFileDismissed() { /* clear the file dialog state */ fileDialogState = ''; } /* receive a file upload from the file upload dialog */ receiveFileUpload(req, query) { /* get the file from the request */ local fields = req.getFormFields(), file; if (fields == 'overflow') { /* error */ fileDialogResult = [InFileFailure, libMessages.webUploadTooBig]; } else if (fields != nil && (file = fields['file']) != nil) { /* got it - save the contents to a local temporary file */ local tmpfile = new TemporaryFile(); local fpTmp = nil; try { /* open the temp file */ fpTmp = File.openRawFile(tmpfile, FileAccessWrite); /* make sure the input file is in raw binary mode */ local fpIn = file.file; fpIn.setFileMode(FileModeRaw); /* copy the contents */ fpTmp.writeBytes(fpIn); /* close the temp file */ fpTmp.closeFile(); /* * Set the dialog result to the temp file. The system * will automatically delete the underlying file system * object when the garbage collector deletes the * TemporaryFile. */ fileDialogResult = [InFileSuccess, tmpfile]; } catch (Exception exc) { /* error - the dialog failed */ fileDialogResult = [InFileFailure, exc.getExceptionMessage()]; /* close and delete the temporary file */ if (fpTmp != nil) fpTmp.closeFile(); if (tmpfile != nil) tmpfile.deleteFile(); } } else { /* no file - consider it a cancellation */ fileDialogResult = [InFileCancel]; } /* the dialog is dismissed */ fileDialogState = ''; } /* show a generic inputDialog dialog */ getInputDialog(icon, prompt, buttons, defaultButton, cancelButton) { /* * if one of the standard button sets was selected, turn it into * a list of localized button names */ switch (buttons) { case InDlgOk: buttons = [libMessages.dlgButtonOk]; break; case InDlgOkCancel: buttons = [libMessages.dlgButtonOk, libMessages.dlgButtonCancel]; break; case InDlgYesNo: buttons = [libMessages.dlgButtonYes, libMessages.dlgButtonNo]; break; case InDlgYesNoCancel: buttons = [libMessages.dlgButtonYes, libMessages.dlgButtonNo, libMessages.dlgButtonCancel]; break; } /* get a suitable localized title corresponding to the icon tyep */ local title = [InDlgIconNone -> libMessages.dlgTitleNone, InDlgIconWarning -> libMessages.dlgTitleWarning, InDlgIconInfo -> libMessages.dlgTitleInfo, InDlgIconQuestion -> libMessages.dlgTitleQuestion, InDlgIconError -> libMessages.dlgTitleError][icon]; /* build the dialog xml description */ inputDialogState = '<inputDialog>' + '<icon><<icon>></icon>' + '<title><<title>></title>' + '<prompt><<prompt.htmlify>></prompt>' + '<buttons>' + buttons.mapAll({b: '<button><<b.htmlify()>></button>'}) .join('') + '</buttons>' + '<defaultButton><<defaultButton>></defaultButton>' + '<cancelButton><<cancelButton>></cancelButton>' + '</inputDialog>'; /* send the request to the client */ inputDialogResult = nil; sendWinEvent(inputDialogState); /* process network events until we get an answer */ processNetRequests({: inputDialogResult != nil }); /* return the dialog result */ return inputDialogResult; } /* receive a selection from the input dialog */ receiveInputDialog(req, query) { /* note the result button */ inputDialogResult = toInteger(query['button']); /* if we didn't get a valid button index, select button 1 */ if (inputDialogResult == 0) inputDialogResult = 1; /* the dialog is no longer open */ inputDialogState = ''; } /* * Post a synthetic event. A synthetic event looks like a regular UI * or network event, but is generated internally instead of being * delivered from the underlying browser or network subsystems. * * 'id' is a string giving the event type. The remaining parameters * are up to each event type to define. */ postSyntheticEvent(id, [params]) { /* add it to the synthetic event queue */ synthEventQueue.append([id] + params); } /* is a synthetic event ready? */ syntheticEventReady() { return synthEventQueue.length() > 0; } /* pull the next synthetic event from the queue */ getSyntheticEvent() { return synthEventQueue.shift(); } /* * file dialog state - this is the XML describing the currently open * file dialog; if the dialog isn't open, this is an empty string */ fileDialogState = '' /* * file dialog result - this is a result list using the same format * as the native inputFile() function */ fileDialogResult = nil /* * input dialog state - this is the XML describing an input dialog * while a dialog is running, or an empty string if not */ inputDialogState = '' /* input dialog result - this is the button number the user selected */ inputDialogResult = nil /* input event state */ inputEventState = '' /* input event result */ inputEventResult = nil /* menuSys state - menu system state (maintained by the menu module) */ menuSysState = '' /* * Synthetic event queue. This is a vector of synthetic events, set * up in the [type, params...] format that the system inputEvent() * function and related functions use. The 'type' code for a * synthetic evente is a string instead of the numeric identifier * that the system functions use. */ synthEventQueue = static new transient Vector() ; /* ------------------------------------------------------------------------ */ /* * Temporary file download page. This page serves temporary files * created via inputFile() as HTTP downloads to the client. */ transient tempFileDownloadPage: WebResource vpath = static new RexPattern('/clienttmp/') processRequest(req, query) { /* * look up the file - the key in our table is the ID string after * the /clienttmp/ path prefix */ local id = query[1].substr(12); local client = ClientSession.find(req); local desc = client.downloads[id]; /* check for cancellation */ if (query['cancel'] != nil) { /* * acknowledge the request - do this before we cancel the * file, since removing the file will remove the link that * fired this request (the order probably isn't a big deal * one way or the other, and we probably can't really control * it anyway as the browser might process the ack and the * event out of order, but just in case) */ sendAck(req); /* if we found the descriptor, cancel it */ if (desc != nil) client.cancelDownload(desc); /* done */ return; } /* if we didn't find it, return failure */ if (desc == nil) { req.sendReply(404); return; } /* open the file */ local fp = nil; try { fp = File.openRawFile(desc.tempFileName, FileAccessRead); } catch (Exception exc) { /* couldn't send the file - send a 404 */ req.sendReply(404); return; } /* set up the download file headers */ local headers = [ 'Content-Disposition: attachment; filename=<<desc.resName>>' ]; try { /* send the file's contents */ req.sendReply(fp, desc.mimeType, 200, headers); } catch (Exception exc) { /* ignore errors */ } /* done with the file */ fp.closeFile(); /* * We've at least tried sending the file, so remove it from the * download list. This was just a temp file, so there's no need * to keep it around even if we ran into an error sending it; if * the send failed, the user can just repeat the operation that * generated the file. */ client.cancelDownload(desc); } /* add a file to our list of downloadable files */ addFile(fileType, client) { /* * Generate a server-side name template based on the file type. * The name doesn't matter to us, but browsers will display it to * the user, and many browsers use the server-side name as the * default name for the newly downloaded file in the "Save File" * dialog. Many browsers also use the suffix to determine the * file type, ignoring any Content-Type headers. */ local tpl = [FileTypeLog -> 'Script#.txt', FileTypeData -> 'File#.dat', FileTypeCmd -> 'Command#.txt', FileTypeText -> 'File#.txt', FileTypeBin -> 'File#.bin', FileTypeT3Image -> 'Story#.t3', FileTypeT3Save -> 'Save#.t3v', * -> 'File#'][fileType]; /* figure the mime type based on the file type */ local mimeType = [FileTypeLog -> 'text/plain', FileTypeCmd -> 'text/plain', FileTypeT3Image -> 'application/x-t3vm-image', FileTypeT3Save -> 'application/x-t3vm-state', * -> 'application/octet-stream'][fileType]; /* replace the '#' in the template with the next ID value */ tpl = tpl.findReplace('#', toString(nextID++)); /* create a new table entry */ local desc = new DownloadTempFile(tpl, mimeType); /* add this download to each client */ client.addDownload(desc); /* return the descriptor */ return desc; } /* next available ID */ nextID = 1 ; /* * Downloadable temporary file descriptor. We create this object when * the program calls inputFile() to ask for a writable file. This lets * the caller create and write a temporary file on the server side; when * the caller is done with the file, we'll offer the file for download to * the client through the UI. */ class DownloadTempFile: object construct(res, mimeType) { tempFileName = new TemporaryFile(); resName = res; resPath = '/clienttmp/<<res>>'; timeCreated = getTime(GetTimeTicks); self.mimeType = mimeType; } /* * File spec interface. This allows the DownloadTempFile to be used as * though it were a filename string. * * When the object is passed to one of the File.open methods, or to * saveGame(), setScriptFile(), etc., the system will call our * getFilename() method to determine the actual underlying file. * We'll return our temporary file object. * * When the underlying file is closed, the system calls our * closeFile() method to notify us. */ getFilename() { return tempFileName; } closeFile() { /* mark the file as ready for download */ isReady = true; /* offering the file to the client as an HTTP download */ webMainWin.offerDownload(self); } /* TemporaryFile object for the local temp file */ tempFileName = nil /* root resource name, and full resource path */ resName = nil resPath = nil /* MIME type */ mimeType = nil /* creation timestamp, as a system tick count value */ timeCreated = 0 /* is the file ready for download? */ isReady = nil /* this is a web temp file */ isWebTempFile = true ; /* ------------------------------------------------------------------------ */ /* * Input event page. The client javascript does a GET on this resource * to send us an input event. */ inputEventPage: WebResource vpath = '/webui/inputEvent' processRequest(req, query) { /* send the event to the main window object */ webMainWin.receiveInputEvent(req, query); /* acknowledge the request */ sendAck(req); } ; /* ------------------------------------------------------------------------ */ /* * Input dialog event page. The web UI sends a GET to this page when the * user selects a button in an input dialog. */ inputDialogPage: WebResource vpath = '/webui/inputDialog' processRequest(req, query) { /* send the event to the main window */ webMainWin.receiveInputDialog(req, query); /* acknowledge the request */ sendAck(req); } ; /* ------------------------------------------------------------------------ */ /* * File dialog event page. This page is used by the IFDB Storage Server * file dialog to return information to the game UI. The IFDB dialog * page can't itself perform scripting actions on the enclosing dialog * frame, since it's being served from a different domain - browsers * prohibit cross-domain scripting for security reasons. The IFDB dialog * must therefore navigate back to a page within the game server domain * in order to return information through scripting. This is that page: * when the IFDB page is ready to return information, it navigates its * frame to this page, passing the return values in the request * parameters. Since this page is served by the game server, within the * game server domain, the browser allows it to use scripting actions on * its enclosing frame. We finish the job by dismissing the dialog in * the UI. */ inputFilePage: WebResource vpath = '/webui/inputFile' processRequest(req, query) { /* set the file selection in the main window */ webMainWin.receiveFileSelection(req, query); /* * We have our response, so dismiss the file dialog. Do this by * sending back a page with script instructions to close the * containing dialog window. */ req.sendReply( '<html><body>' + '<script type="text/javascript">' + 'window.parent.dismissDialogById("inputFile");' + '</script>' + '</body></html>'); } ; /* * Cancel the input dialog. This is called from the UI directly to * cancel the file selection, when the user closes the dialog through the * enclosing main page UI rather than from within the dialog. This is * useful if the dialog page fails to load, for example. * * Note: the upload file dialog also uses this. The upload dialog is * basically a variation on the regular input file dialog. */ inputFileCancel: WebResource vpath = '/webui/inputFileDismissed' processRequest(req, query) { /* note that the dialog has been dismissed */ webMainWin.inputFileDismissed(); /* acknowledge the request */ sendAck(req); } ; /* * Receive results from the input file dialog */ uploadFilePage: WebResource vpath = '/webui/uploadFileDialog' processRequest(req, query) { /* send the request to the main window */ webMainWin.receiveFileUpload(req, query); /* send back a script to dismiss the dialog */ req.sendReply( '<html><body>' + '<script type="text/javascript">' + 'window.parent.dismissDialogById("uploadFile");' + '</script>' + '</body></html>'); } ; /* ------------------------------------------------------------------------ */ /* * Receive the client's screen name setting */ setScreenNamePage: WebResource vpath = '/webui/setScreenName' processRequest(req, query) { /* set the name in the client session */ local cli = ClientSession.find(req); if (cli != nil) cli.screenName = query['name']; /* acknowledge the request */ sendAck(req); } ;
TADS 3 Library Manual
Generated on 5/16/2013 from TADS version 3.1.3
Generated on 5/16/2013 from TADS version 3.1.3