Getting some HTML5 Local Storage goodness in AS3

After the success of making Flash and Unity play nice with each other, it's time to add HTML5 to the mix. This time around: mashing LocalStorage and SharedObjects together!

A bit of background

At work, we're currently soft-launching a game, with Facebook as one of the platforms. We use a SharedObject to hold some params, one of which being if you'd played the tutorial or not. Basically, if this param was set, you'd join the game normally, otherwise, we'd assume that you're a new player and start you on the tutorial.

While testing, it came out that on Safari on Mac, everytime you reloaded the page, you'd play the tutorial. Long story short, Safari has a default setting stopping 3rd party websites from setting cookies (and by extension SharedObjects). As our Facebook app is served in an iFrame, it's considered a 3rd party, and so SharedObjects would never get saved.

After playing around with some of the different, proposed fixes on StackOverflow, nothing was working. I could get PHP cookies to work, and the SharedObject to work on our own domain, but I couldn't read/write them when on Facebook itself.

A friend of mine, Amaury suggested going through LocalStorage, and behold it worked!

Because, obviously, if something is bad in Flash, it's OK in JavaScript ;)

WebStorageLSO

With that in mind, and a public holiday to waste, WebStorageLSO was born!

Basically, when we init the WebStorageLSO class, it writes some JavaScript bridge code to handle all your CRUD desires. Then, when you want to do something, ActionScript calls JavaScript, which does all the heavy lifting.

Each WebStorageLSO can be given a name (so you can have more than one), which is used to prefix all the keys, so there's less potential conflict with other code accessing LocalStorage. You can also specify if you want the WebStorageLSO to persist until you remove it, or just for the session. Session WebStorageLSOs will last through page reloads and refreshes, but are cleared when the tab/browser is closed.

One thing to keep in mind; LocalStorage is a simple key/value String pairing, so any value that's not a String gets automatically converted to a String when it's saved. Depending on what you're saving, this may or may not be a problem.

Related to that, there's no automatic serialisation to AMF bytecode, as you get with AS3 SharedObjects, so you'll need to serialise any objects yourself (XML or JSON will do the trick).

Pros & cons

WebStorageLSO isn't a straight swap for SharedObjects, so you'll need to decide if it's good for you.

Pros:

  • It works when SharedObjects won't
  • You get so much more storage by default: around 5MB (though Wikipedia says anywhere up to 25MB), which trounces Flash's 100Kb. Ask for more than than in Flash, and you get a prompt. In LocalStorage though, that's all you get
  • They're a bit more accessible than SharedObjects - you can edit values in any dev console
  • You can have session WebStorageLSOs, which clean themselves up when the tab is closed

Cons:

  • It needs JavaScript/LocalStorage to work. Happily, it's supported all the way down to IE8, but you should know that users can disable it for your site (and apparently in Safari private browsing mode, it's available, but you can't save anything - WebStorageLSO catches this case)
  • Values are Strings only, meaning serialisation/conversion needs to be done manually
  • You can edit values in any dev console; so either don't stock sensitive stuff, or do some sort of encrypting
  • It uses ExternalInterface, which isn't the quickest

The code

Here's the code; you can also download it below. Also below, but which isn't necessary, is the un-minified version of the JavaScript used.

NOTE: if you're familiar with LocalStorage, you might know that there's a StorageEvent that gets fired when the storage is updated. I was originally going to bind it as a quick way to check if someone is tampering with the saved data, but unfortunately it only fires for other windows - i.e. if you had two tabs open, and tab A changes a value, tab B would be the only one that recevies the event.

It does fire if you modify the storage using the developer console in Chrome and IE, but not in Firefox - and I think it only does it in IE because they implemented it wrong.

This behaviour is normal, but seems a bit lame. Anyway, seeing as you probably won't be having your game open in two separate tabs, I didn't see much use for it.

The code:

package
{
	import flash.external.ExternalInterface;
	
	/**
	 * An interface for using a web page's LocalStorage as a local SharedObject
	 * @author Damian Connolly
	 */
	public class WebStorageLSO
	{
		
		/********************************************************************************/
		
		private static const M_JS_IS_AVAILABLE:String 	= "WebStorageLSO.isAvailable"; 	// the js function to check if we're available
		private static const M_JS_ADD:String 			= "WebStorageLSO.add"; 			// the js function to add something to web storage
		private static const M_JS_GET:String 			= "WebStorageLSO.get"; 			// the js function to get something from web storage
		private static const M_JS_REMOVE:String 		= "WebStorageLSO.remove"; 		// the js function to remove something from web storage
		private static const M_JS_REMOVE_ALL:String 	= "WebStorageLSO.removeAll"; 	// the js function to remove everything from this lso from web storage
		private static const M_EI_CALL_ERROR:String 	= "ExternalInterfaceCallError"; // what we return from _callJS when we couldn't call our method (as it can legitimately return null) 
		
		/********************************************************************************/
		
		private static var m_isAvailable:Boolean 			= false; // is web storage available?
		private static var m_hasCheckedIfAvailable:Boolean 	= false; // have we checked if web storage is available?
		private static var m_isInited:Boolean 				= false; // have we inited yet (i.e. copied our js)
		
		/********************************************************************************/
		
		/**
		 * Checks if web storage is available and that we can write to it
		 */
		public static function get isAvailable():Boolean
		{
			// if we've already checked, just return
			if ( WebStorageLSO.m_hasCheckedIfAvailable )
				return WebStorageLSO.m_isAvailable;
			
			// from this point on, we're considered to have checked
			WebStorageLSO.m_hasCheckedIfAvailable = true;
			
			// we need to be inited before we can do anything
			if ( !WebStorageLSO.m_isInited )
			{
				// if we couldn't init, then just return
				if ( !WebStorageLSO._init() )
				{
					WebStorageLSO.m_isAvailable = false;
					return WebStorageLSO.m_isAvailable;
				}
			}
			
			// we need external interface
			if ( !ExternalInterface.available )
			{
				WebStorageLSO.m_isAvailable = false;
				return WebStorageLSO.m_isAvailable;
			}
			
			// call our external method
			WebStorageLSO.m_isAvailable = WebStorageLSO._callJS( WebStorageLSO.M_JS_IS_AVAILABLE );
			return WebStorageLSO.m_isAvailable;
		}
		
		/********************************************************************************/
		
		// called when we want to init the WebStorageLSO JS code
		private static function _init():Boolean
		{
			// if we've already inited, return
			if ( WebStorageLSO.m_isInited )
				return true;
			
			// from this point on, consider us inited
			WebStorageLSO.m_isInited = true;
			
			// we need external interface
			if ( !ExternalInterface.available )
			{
				trace( "3:Can't init WebStorageLSO as ExternalInterface isn't available" );
				return false;
			}
			
			// write our external js
			if ( WebStorageLSO._callJS( WebStorageLSO.M_JS_CODE ) == WebStorageLSO.M_EI_CALL_ERROR )
			{
				trace( "3:Couldn't write our external JS to init WebStorageLSO" );
				return false;
			}
			return true;
		}
		
		// calls an external js function
		private static function _callJS( funcName:String, ... parameters ):*
		{
			// if no external interface, just return
			if ( !ExternalInterface.available )
				return WebStorageLSO.M_EI_CALL_ERROR;
			
			// wrap in a try..catch as it can fail
			try
			{
				// if we have any parameters, unshift the function name and use the apply() method
				if ( parameters != null && parameters.length > 0 )
				{
					parameters.unshift( funcName );
					return ExternalInterface.call.apply( null, parameters );
				}
				else
					return ExternalInterface.call( funcName );
			}
			catch ( se:SecurityError ) { trace( "3:A security error occured when trying to call external web storage function '" + funcName + "': Error " + se.errorID + ": " + se.name + ": " + se.message ); }
			catch ( e:Error ) { trace( "3:Couldn't call external web storage function '" + funcName + "': Error " + e.errorID + ": " + e.name + ": " + e.message ); }
			return WebStorageLSO.M_EI_CALL_ERROR;
		}
		
		/********************************************************************************/
		
		private var m_name:String 			= null; // the name for our lso
		private var m_isForSession:Boolean 	= false;// is this for a session, or for keeps?
		private var m_internal:Object		= null; // our internal "lso", for speed, and in the case where WebStorage isn't available
		
		/********************************************************************************/
		
		/**
		 * Creates a new WebStorageLSO object. This lets us save key/value pairs in the browsers
		 * WebStorage
		 * @param name The name for this shared object. If non-null and non-empty, all keys will 
		 * be prepended with this, plus a "." to avoid possible conflicts with other data in 
		 * shared storage
		 * @param isForSession If this data just for the session, or for keeps. Session data will
		 * persist across web page reloads and restores, but will be cleared when the tab is closed. If
		 * false, data will persist until explicitly cleared
		 */
		public function WebStorageLSO( name:String = "WebStorageLSO", isForSession:Boolean = false )
		{
			this.m_name 		= ( name == null || name == "" ) ? null : name + ".";
			this.m_isForSession = isForSession;
			this.m_internal		= new Object;
		}
		
		/**
		 * Destroys the WebStorageLSO, clears any info from the disk, and clears it for garbage collection
		 */
		public function destroy():void
		{
			// clear our internal cache
			for ( var key:String in this.m_internal )
			{
				this.m_internal[key] = null;
				delete this.m_internal[key];
			}
			
			// call js to remove anything external
			if ( WebStorageLSO.isAvailable )
				WebStorageLSO._callJS( WebStorageLSO.M_JS_REMOVE_ALL, this.m_isForSession, this.m_name );
		}
		
		/**
		 * Adds an item to WebStorage
		 * @param key The String key that we want to save our value under. It will be prepended with
		 * the name of the WebStorageLSO (unless null was passed)
		 * @param value The String value that we want to save
		 * @return True if we could save the item, false otherwise
		 */
		public function addItem( key:String, value:String ):Boolean
		{
			// get our real key
			key = this._getKey( key );
			
			// add it to our internal object
			this.m_internal[key] = value;
			
			// check to see if we can save it
			if ( WebStorageLSO.isAvailable )
			{
				// call our external js
				if ( !( WebStorageLSO._callJS( WebStorageLSO.M_JS_ADD, this.m_isForSession, key, value ) as Boolean ) )
				{
					trace( "3:Can't store a value for key '" + key + "'. This could either be because JavaScript or WebStorage aren't available, or that we've run out of space" );
					return false;
				}
			}
			else
				trace( "3:Can't store value '" + value + "' with name '" + key + "' in the LSO as it's not available" );
				
			return true; // we still saved it internally
		}
		
		/**
		 * Gets the String value for a specific key
		 * @param key The key for the value that we're looking for
		 * @return The String value, or null if we don't have it, or couldn't get it
		 */
		public function getItem( key:String ):String
		{
			// get our real key
			key = this._getKey( key )
			
			// if we have it in our internal lso, then just return it
			if ( key in this.m_internal )
				return this.m_internal[key];
				
			// call our external js to return the object
			var ret:String = null;
			if ( WebStorageLSO.isAvailable )
				ret = WebStorageLSO._callJS( WebStorageLSO.M_JS_GET, this.m_isForSession, key ) as String;
			if ( ret != WebStorageLSO.M_EI_CALL_ERROR )
			{
				this.m_internal[key] = ret;
				return ret;
			}
			return null;
		}
		
		/**
		 * Removes a key/value pair from web storage
		 * @param key The key for the key/value pair that we want to remove
		 * @return True if the pair was removed (or doesn't exist to be removed), false otherwise
		 */
		public function removeItem( key:String ):Boolean
		{
			// get our real key
			key = this._getKey( key );
			
			// check if we need to remove it from our internal
			var isRemovedFromInternal:Boolean = false;
			if ( key in this.m_internal )
			{
				isRemovedFromInternal	= true;
				this.m_internal[key] 	= null;
				delete this.m_internal[key];
			}
			
			// try and remove it from our external
			var isRemovedFromExternal:Boolean = false;
			if ( WebStorageLSO.isAvailable )
				isRemovedFromExternal = WebStorageLSO._callJS( WebStorageLSO.M_JS_REMOVE, this.m_isForSession, key ) as Boolean;
			return ( isRemovedFromInternal || isRemovedFromExternal );
		}
		
		/**
		 * The String version of the LSO
		 */
		public function toString():String
		{
			return "[WebStorageLSO name: " + this.m_name + "]";
		}
		
		/********************************************************************************/
		
		// gets the key, prepended with our name, if we have one
		private function _getKey( key:String ):String
		{				
			// if we have a name, add it
			return ( this.m_name != null ) ? this.m_name + key : key;
		}
		
		/********************************************************************************/
		
		// the js that we're going to write to the page for our methods
		private static const M_JS_CODE:XML = <script>
			<![CDATA[
				function(){window.WebStorageLSO=window.WebStorageLSO||{isInited:false,ls:null,ss:null,isAvailable:function(){if(!this._hasStorage())
				return false;try{this.ss.setItem('test','1');this.ss.removeItem('test');return true;}catch(e){return false;}
				return true;},add:function(isForSession,key,val){if(!this._hasStorage())
				return false;try{if(isForSession)
				this.ss.setItem(key,val);else
				this.ls.setItem(key,val);}catch(e){return false;}
				return true;},get:function(isForSession,key){if(!this._hasStorage())
				return null;return(isForSession)?this.ss.getItem(key):this.ls.getItem(key);},remove:function(isForSession,key){if(!this._hasStorage())
				return false;if(isForSession)
				this.ss.removeItem(key);else
				this.ls.removeItem(key);return true;},removeAll:function(isForSession,keyStub){if(!this._hasStorage())
				return false;if(typeof keyStub==='undefined'||keyStub===null||keyStub==='')
				{if(isForSession)
				this.ss.clear();else
				this.ls.clear();}
				else
				{var s=(isForSession)?this.ss:this.ls;for(var i=s.length-1;i>=0;i--)
				{var key=s.key(i);if(key.indexOf(keyStub)==0)
				s.removeItem(key);}}
				return true;},_hasStorage:function(){if(!this.isInited)
				{this.isInited=true;try{this.ls=window.localStorage;this.ss=window.sessionStorage;}catch(e){console.error("Couldn't init WebStorageLSO: "+e);}
				if(this.ls===undefined||!this.isAvailable)
				{this.ls=null;this.ss=null;}}
				return(this.ls!==null&&this.ss!==null);},}}
			]]>
		</script>;
	
	}

}

P.S.

If anybody knows of a proper way of getting SharedObjects working in a 3rd party domain (e.g. iFrame) in Safari, I'd love to know about it.

Updates

15/05/14:
Some quick bug fixes and updates:

  • Fixed some bugs relating to knowing when calling ExternalInterface worked or not
  • removeAll() is now destroy()
  • There's an internal Object that's used as a cache, so that getting values that have already been fetched is quicker

Share: