October 19th, 2009
Lesson, the first.
I’ve actually built [rssTunes][1] three times, and we’re about to embark on a fourth build.
Each time I’ve completely scrapped most of my code, not just out of some perfectionist’s stupidity, though I’m sure that played into it, but more because after each build was complete, I realized I’d approached the entire project incorrectly.
Each build failed for very specific reasons. Today, I’m gonna focus on just one of them.
### Security Sandboxes
My first build of the player was strictly a proof-of-concept.
Whenever I start a project, one of the first things I do is sort out exactly how I’m gonna accomplish specific features. If I’m dealing with a new language, or platform – or in this case both – I’ll try to cobble together some very basic proofs that show my basic approach, and how much time I think it’ll take me, isn’t completely off.
rssTunes isn’t a very complicated application, at least not compared to other things. It downloads a list of MP3 files from a server, and plays them in linear order. It shows you which song is currently playing, and who the song was recorded by. This is not rocket science.
In my first build, I wired up a single MediaElement control, added a pause and play button, and some text fields to show the song and artist name. I threw an example file up on our server, and plugged the excellent [TagLibSharp][2] into place. Everything seemed to work just dandy, so I called it a day.
A few days later, I wanted to battle-test the ability to download a list of tracks. I wrote a quick RSS parser using [SimplePie][3], that spit out a JSON-encoded array of tracks, and then wired up a simple class inside the application that would keep track of a list of files and let the user advance trough them, swapping out MediaElement’s DataContext as the user progressed through the list of files.
The music played. The song and artist name did not update. In fact, instead of working, it threw a rather heinous security exception and then the application crashed. After pulling my hair out, cursing the gods, and swearing off C#, I went back and started pulling everything apart. I swapped out my new track-listing class for my original one, which listed only the single file on my server. Everything worked again. I swapped in a single track from my list of downloaded tracks, and the application went boom. I paced, and smoked half-a-pack of cigarettes. I cursed the gods again. I started to become convinced that our idea was impossible, or at least not possible within our deadline, and I’d have to write an entire MP3 decoder on my own in C#. I get a little over-dramatic sometimes.
And then I remembered sandboxing.
### An explanation.
Silverlight, like any internet plugin, is really rather dangerous. It presents a complete application environment where a developer can do any number of utterly awesome things, and can do them without any user interaction other than opening a web page.
One of the most basic really-dangerous things it can do is talk to a web server. I realize that doesn’t sound all that dangerous, but hang on, it gets better. Silverlight talks to web servers through your web browser. Queue scary music.
Every time your web browser makes a call to a web server – or opens a web page in laymen’s terms – it does more than just say, “Hey, give me a web page.” It also sends with it a whole slew of interesting tidbits about you, the user. These tidbits are usually stored in [cookies][4], and _every_ cookie your web browser has stored from a specific web server is sent over _every_ time it makes _any_ request to that web server.
That’s how things like “logging into” a web site work. You “log in”, and the web server sends down a cookie to your web browser with some identifying bit of information. Next time you try to open any other web page from that same web site, your web browser sends that cookie along.
Since Silverlight is asking your web browser to make requests to web servers for it, any request is going to have all of those delicious cookies attached to it.
Which means, from my Silverlight application, I could, for instance, start making simple requests to every known bank web site, searching for one in which you are actively logged into, and then wire a whole bunch of your money to myself. Because, for all your bank’s web server knowledge, my Silverlight application, which is running in your web browser, is doing things on your behalf.
Clearly, no one in their right mind would run Silverlight, or Flash, or anything else for that matter, if this were possible (though it’s completely possible to do this using just HTML and JavaScript right now using something called [cross-site scripting hacks][5], but that’s completely outside the jurisdiction of this post).
To make Silverlight more secure, Microsoft prevents my application from making arbitrary requests to any web server at all. It “sandboxes” my application, and before it allows any request to be made, it checks with each web server and asks the web server whether or not the application is allowed to make the request.
It does this by checking for a file on the web server called either cross_domain.xml, the same file Flash uses to solve this problem, or clientaccesspolicy.xml, which is specific to Silverlight. If one of those files doesn’t exist on the web server, Silverlight won’t let you make a request to that web server.
But there are exceptions.
For instance, in my first build, the music played just fine no matter what server I was requesting the music from. Thankfully, Microsoft added an exemption to this rule whenever Silverlight can prevent me, the developer, from accessing the actual data contained in whatever file I’m requesting. Since MediaElement handles decoding the MP3 file and playing it for me, I have no need to access the actually data within the MP3 file. So that works just fine.
But to figure out what the title of the song playing is, and who it’s by, I have to read parts of the file looking for [ID3 tags][6]. MediaElement provides no access to this information on it’s own (hey, Microsoft, fix that!) so I have to read in the MP3 file and search for the tags on my lonesome.
Obviously, this is impossible given the sandboxing.
I had to rethink my whole approach.
### The fix.
Now, the generally fix for this problem is to build a proxy server. Basically, you build a script on your server that will request files and web pages for you and pass back the results.
This is a generally terrible idea to me.
First, in our case, we’re not talking about passing through small requests. We’re talking about passing back potentially gigantic MP3 files. Having my server come between you and the MP3 file could cause massive delays, and completely ruin your listening experience.
So proxying everything wasn’t an option.
However, I’d already realized that building a full-fledge RSS parser in Silverlight would be a waste of time. The security sandbox would get in my way, not to mention the massive delays in start-up time and refreshing your feeds. I was originally going to build a simple proxying RSS reader, that would take in a single RSS feed URL and return a list of posts for Silverlight to find MP3 files from.
So why not just have the server do all the work?
If the rssTunes application become a thin-client to a robust web service, all of my problems would be solved. Huzzah!
### Making the client.
The next proof-of-concept scrapped all of the ID3 tag reading junk, all of the RSS reading logic, and instead assumed it would be making a call to the same web server it was hosted on, and requesting a list of not just MP3 file URLs, but a full record for each file, including all know ID3 tags.
By pulling all of the parse-and-fetch logic out of the Silverlight application, I could focus on making it as fast and responsive as possible, and would have more time to add bells and whistles later.
I wrote a small army of PHP scripts that live on the Mess with Silverlight web server, that maintained a list a feeds for each account, and periodically fetched new items, looked for MP3 files within them, and then parsed each files ID3 tags.
While it’s not strictly related to Silverlight development, I had a few problems parsing ID3 tags as well.
### Quick follow-up.
If you were using the first public build of the application, you may remember that many of the songs would show “Unknown Artist” or “Unknown Track”.
The problem was simple. I didn’t want to download every MP3 file in it’s entirety. It would waste our bandwidth, and it would take too long to refresh feeds for our users.
ID3 tags are stored at the beginning of an MP3 file. Their stored there specifically to ensure that, if you’re streaming an MP3 file off of a web server, you don’t have to wait for the entire file to download before seeing any information about the track.
The problem with _every_ ID3 parsing script I could find, PHP or otherwise, is that they assume you’ve downloaded the entire file. I’d managed to “cheat”, but only downloading the first 16k of each MP3 file into a temporary file, and sending that portion of the file of the parsers. For most files, this worked just fine. However, for some, it failed miserably.
The only solution was to build my own ID3 tag parser that understood it was streaming the file from a web server, and would only download information as it needed it.
Based loosely on this class from [phpClasses.org][7], the following PHP class takes in a URL, and downloads only the exact amount of the file necessary to parse the ID3 tags.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 | <?php define('HEADER_LENGTH', 10); define('HEADER_LENGTH_V', 6); define('CHUNK_SIZE', 1024); class mp3id3 { public $has_error; public $frames; private $path; private $read_so_far; private $id3_version; private $id3_header; private $tag_size; private $id3_header_data; private $fh; private $data_so_far; private $flags; private $full_data; private $temporary_file; var $v2_frame_descriptions = array( 'CNT' => 'Play counter', 'COM' => 'Comments', 'CRA' => 'Audio encryption', 'CRM' => 'Encrypted meta frame', 'ETC' => 'Event timing codes', 'EQU' => 'Equalization', 'GEO' => 'General encapsulated object', 'IPL' => 'Involved people list', 'LNK' => 'Linked information', 'MCI' => 'Music CD Identifier', 'MLL' => 'MPEG location lookup table', 'PIC' => 'Attached picture', 'POP' => 'Popularimeter', 'REV' => 'Reverb', 'RVA' => 'Relative volume adjustment', 'SLT' => 'Synchronized lyric/text', 'STC' => 'Synced tempo codes', 'TAL' => 'Album/Movie/Show title', 'TBP' => 'BPM (Beats Per Minute)', 'TCM' => 'Composer', 'TCO' => 'Content type', 'TCR' => 'Copyright message', 'TDA' => 'Date', 'TDY' => 'Playlist delay', 'TEN' => 'Encoded by', 'TFT' => 'File type', 'TIM' => 'Time', 'TKE' => 'Initial key', 'TLA' => 'Language(s)', 'TLE' => 'Length', 'TMT' => 'Media type', 'TOA' => 'Original artist(s)/performer(s)', 'TOF' => 'Original filename', 'TOL' => 'Original Lyricist(s)/text writer(s)', 'TOR' => 'Original release year', 'TOT' => 'Original album/Movie/Show title', 'TP1' => 'Lead artist(s)/Lead performer(s)/Soloist(s)/Performing group', 'TP2' => 'Band/Orchestra/Accompaniment', 'TP3' => 'Conductor/Performer refinement', 'TP4' => 'Interpreted, remixed, or otherwise modified by', 'TPA' => 'Part of a set', 'TPB' => 'Publisher', 'TRC' => 'ISRC (International Standard Recording Code)', 'TRD' => 'Recording dates', 'TRK' => 'Track number/Position in set', 'TSI' => 'Size', 'TSS' => 'Software/hardware and settings used for encoding', 'TT1' => 'Content group description', 'TT2' => 'Title/Songname/Content description', 'TT3' => 'Subtitle/Description refinement', 'TXT' => 'Lyricist/text writer', 'TXX' => 'User defined text information frame', 'TYE' => 'Year', 'UFI' => 'Unique file identifier', 'ULT' => 'Unsychronized lyric/text transcription', 'WAF' => 'Official audio file webpage', 'WAR' => 'Official artist/performer webpage', 'WAS' => 'Official audio source webpage', 'WCM' => 'Commercial information', 'WCP' => 'Copyright/Legal information', 'WPB' => 'Publishers official webpage', 'WXX' => 'User defined URL link frame' ); var $v3_frame_descriptions = array( 'AENC' => 'Audio encryption', 'APIC' => 'Attached picture', 'COMM' => 'Comments', 'COMR' => 'Commercial frame', 'ENCR' => 'Encryption method registration', 'EQUA' => 'Equalization', 'ETCO' => 'Event timing codes', 'GEOB' => 'General encapsulated object', 'GRID' => 'Group identification registration', 'IPLS' => 'Involved people list', 'LINK' => 'Linked information', 'MCDI' => 'Music CD identifier', 'MLLT' => 'MPEG location lookup table', 'OWNE' => 'Ownership frame', 'PRIV' => 'Private frame', 'PCNT' => 'Play counter', 'POPM' => 'Popularimeter', 'POSS' => 'Position synchronization frame', 'RBUF' => 'Recommended buffer size', 'RVAD' => 'Relative volume adjustment', 'RVRB' => 'Reverb', 'SYLT' => 'Synchronized lyric/text', 'SYTC' => 'Synchronized tempo codes', 'TALB' => 'Album/Movie/Show title', 'TBPM' => 'BPM (beats per minute)', 'TCOM' => 'Composer', 'TCON' => 'Content type', 'TCOP' => 'Copyright message', 'TDAT' => 'Date', 'TDLY' => 'Playlist delay', 'TENC' => 'Encoded by', 'TEXT' => 'Lyricist/Text writer', 'TFLT' => 'File type', 'TIME' => 'Time', 'TIT1' => 'Content group description', 'TIT2' => 'Title/songname/content description', 'TIT3' => 'Subtitle/Description refinement', 'TKEY' => 'Initial key', 'TLAN' => 'Language(s)', 'TLEN' => 'Length', 'TMED' => 'Media type', 'TOAL' => 'Original album/movie/show title', 'TOFN' => 'Original filename', 'TOLY' => 'Original lyricist(s)/text writer(s)', 'TOPE' => 'Original artist(s)/performer(s)', 'TORY' => 'Original release year', 'TOWN' => 'File owner/licensee', 'TPE1' => 'Lead performer(s)/Soloist(s)', 'TPE2' => 'Band/orchestra/accompaniment', 'TPE3' => 'Conductor/performer refinement', 'TPE4' => 'Interpreted, remixed, or otherwise modified by', 'TPOS' => 'Part of a set', 'TPUB' => 'Publisher', 'TRCK' => 'Track number/Position in set', 'TRDA' => 'Recording dates', 'TRSN' => 'Internet radio station name', 'TRSO' => 'Internet radio station owner', 'TSIZ' => 'Size', 'TSRC' => 'ISRC (international standard recording code)', 'TSSE' => 'Software/Hardware and settings used for encoding', 'TYER' => 'Year', 'UFID' => 'Unique file identifier', 'USER' => 'Terms of use', 'USLT' => 'Unsychronized lyric/text transcription', 'WCOM' => 'Commercial information', 'WCOP' => 'Copyright/Legal information', 'WOAF' => 'Official audio file webpage', 'WOAR' => 'Official artist/performer webpage', 'WOAS' => 'Official audio source webpage', 'WORS' => 'Official internet radio station homepage', 'WPAY' => 'Payment', 'WPUB' => 'Publishers official webpage' ); // Methods public function __construct($path, $delayed_parse = false) { $this->frames = array(); $this->path = $path; $this->fh = @fopen($this->path, "rb"); if (!$this->fh) $this->has_error = true; if (!$delayed_parse) $this->parse(); } public function __destruct() { if ($this->fh) @fclose($this->fh); } public function parse() { // Ensure file has a header if (!$this->get_size_of_tags()) $this->has_error = true; if (!$this->read_tags()) $this->has_error = true; } public function get_size_of_tags() { if ($this->fh) { $this->id3_header_data = fread($this->fh, 10); $this->id3_header = @unpack('a3identifier/Cversion/Crevision/Cflag/Csize0/Csize1/Csize2/Csize3', $this->id3_header_data); if(!$this->id3_header || $this->id3_header['identifier'] != 'ID3') return false; $this->tag_size = ($this->id3_header['size0'] & 0x7F) << 21 | ($this->id3_header['size1'] & 0x7F) << 14 | ($this->id3_header['size2'] & 0x7F) << 7 | ($this->id3_header['size3']); if(($this->tag_size = intval($this->tag_size)) < 1) return false; else return $this->tag_size; } } public function read_tags() { if ($this->id3_header['version'] == 2) $this->read_v2_tags(); if ($this->id3_header['version'] > 2) $this->read_v4_tags(); return true; } public function artist() { if (($this->id3_header['version'] == 2) && isset($this->frames['TP1'])) return $this->frames['TP1']->data; if (($this->id3_header['version'] > 2) && isset($this->frames['TPE1'])) return $this->frames['TPE1']->data; return "Unknown Artist"; } public function song() { if (($this->id3_header['version'] == 2) && isset($this->frames['TT2'])) return $this->frames['TT2']->data; if (($this->id3_header['version'] > 2) && isset($this->frames['TIT2'])) return $this->frames['TIT2']->data; return "Unknown Track"; } public function read_v2_tags() { $temporary_file = tmpfile(); for($read = 0; $read < $this->tag_size; $read += CHUNK_SIZE) { fwrite($temporary_file, fread($this->fh, CHUNK_SIZE)); } fseek($temporary_file, 0); while(1) { if(ftell($temporary_file) >= $this->tag_size) break; $frame = new StdClass(); $header_chunck = fread($temporary_file, HEADER_LENGTH_V); $frame->header = @unpack('a3frameid/Csize0/Csize1/Csize2', $header_chunck); if(!$frame->header || !$frame->header['frameid']) continue; $frame->id = $frame->header['frameid']; $frame->description = 'Unknown'; $frame->header['size'] = $frame->header['size0'] << 16 | $frame->header['size1'] << 8 | $frame->header['size2']; if(($frame->size = $frame->header['size']) < 1 || (ftell($temporary_file) + $frame->size) > $this->tag_size) continue; if(isset($this->v2_frame_descriptions[$frame->id])) { $frame->description = $this->id3v2_frame_descriptions[$frame->id]; } else { switch(strtoupper($frameid{0})) { case 'T': $frame->description = 'Text'; break; case 'W': $frame->description = 'URL'; break; } } $frame->charsetdata = @unpack('ctype', fread($temporary_file, 1)); $frame->charset = null; switch (intval($frame->charsetdata['type'])) { case 0: $frame->charset = 'ISO-8859-1'; break; case 1: $frame->charset = 'UTF-16'; break; case 2: $frame->charset = 'UTF-16BE'; break; case 3: $frame->charset = 'UTF-8'; break; } if ($frame->charset) { $frame->datasize = $frame->size - 1; } else { $frame->datasize = $frame->size; fseek($temporary_file, ftell($temporary_file) - 1); } $frame->data = @unpack("a{$frame->datasize}data", fread($temporary_file, $frame->datasize)); $frame->data = (intval($frame->charsetdata['type']) > 0) ? utf8_decode($this->utf16_to_utf8($frame->data['data'])) : $frame->data['data']; if ($frame->id == 'COMM') { $frame->lang = substr($frame->data, 0, 3); $frame->data = substr($frame->data, 3 + ($frame->data{3} == "\x00" ? 1 : 0)); } else { $frame->lang = ''; } $this->frames[$frame->id] = $frame; if (isset($this->frames['TP1']) && isset($this->frames['TT2'])) break; } fclose($temporary_file); } public function read_v4_tags() { $temporary_file = tmpfile(); for($read = 0; $read < $this->tag_size; $read += CHUNK_SIZE) { fwrite($temporary_file, fread($this->fh, CHUNK_SIZE)); } fseek($temporary_file, 0); while(1) { if(ftell($temporary_file) >= $this->tag_size) break; $frame = new StdClass(); $header_chunck = fread($temporary_file, HEADER_LENGTH); $frame->header = @unpack('a4frameid/Nsize/Cflag0/Cflag1', $header_chunck); if(!$frame->header || !$frame->header['frameid']) continue; $frame->id = $frame->header['frameid']; $frame->description = 'Unknown'; if(($frame->size = $frame->header['size']) < 1 || (ftell($temporary_file) + $frame->size) > $this->tag_size) continue; if(isset($this->v3_frame_descriptions[$frame->id])) { $frame->description = $this->id3v2_frame_descriptions[$frame->id]; } else { switch(strtoupper($frameid{0})) { case 'T': $frame->description = 'Text'; break; case 'W': $frame->description = 'URL'; break; } } $frame->flag = array($this->convert_flag($frame->header['flag0']), $this->convert_flag($frame->header['flag1'])); $frame->charsetdata = @unpack('ctype', fread($temporary_file, 1)); $frame->charset = null; switch (intval($frame->charsetdata['type'])) { case 0: $frame->charset = 'ISO-8859-1'; break; case 1: $frame->charset = 'UTF-16'; break; case 2: $frame->charset = 'UTF-16BE'; break; case 3: $frame->charset = 'UTF-8'; break; } if ($frame->charset) { $frame->datasize = $frame->size - 1; } else { $frame->datasize = $frame->size; fseek($temporary_file, ftell($temporary_file) - 1); } $frame->data = @unpack("a{$frame->datasize}data", fread($temporary_file, $frame->datasize)); $frame->data = (intval($frame->charsetdata['type']) > 0) ? utf8_decode($this->utf16_to_utf8($frame->data['data'])) : $frame->data['data']; if ($frame->id == 'COMM') { $frame->lang = substr($frame->data, 0, 3); $frame->data = substr($frame->data, 3 + ($frame->data{3} == "\x00" ? 1 : 0)); } else { $frame->lang = ''; } $this->frames[$frame->id] = $frame; if (isset($this->frames['TPE1']) && isset($this->frames['TIT2'])) break; } fclose($temporary_file); } private function get_frame($name) { if (isset($this->frames[$name])) if (isset($this->frames[$name]->data)) return $this->frames[$name]->data; else return "Unknown"; } private function convert_flag($flag, $convtobin = true, $length = 8) { $flag = $convtobin ? decbin($flag) : $flag; $recruit = $length - strlen($flag); if($recruit < 1) return $flag; return sprintf('%0'.$length.'d', $flag); } private function utf16_to_utf8($str) { $c0 = ord($str[0]); $c1 = ord($str[1]); if ($c0 == 0xFE && $c1 == 0xFF) { $be = true; } else if ($c0 == 0xFF && $c1 == 0xFE) { $be = false; } else { return $str; } $str = substr($str, 2); $len = strlen($str); $dec = ''; for ($i = 0; $i < $len; $i += 2) { $c = ($be) ? ord($str[$i]) << 8 | ord($str[$i + 1]) : ord($str[$i + 1]) << 8 | ord($str[$i]); if ($c >= 0x0001 && $c <= 0x007F) { $dec .= chr($c); } else if ($c > 0x07FF) { $dec .= chr(0xE0 | (($c >> 12) & 0x0F)); $dec .= chr(0x80 | (($c >> 6) & 0x3F)); $dec .= chr(0x80 | (($c >> 0) & 0x3F)); } else { $dec .= chr(0xC0 | (($c >> 6) & 0x1F)); $dec .= chr(0x80 | (($c >> 0) & 0x3F)); } } return $dec; } } ?> |
[1]:http://www.rsstunes.com
[2]:http://developer.novell.com/wiki/index.php/TagLib_Sharp
[3]:http://simplepie.org/
[4]:http://en.wikipedia.org/wiki/HTTP_cookie
[5]:http://en.wikipedia.org/wiki/Cross-site_scripting
[6]:http://www.id3.org/Developer_Information
[7]:http://www.phpclasses.org/browse/package/5275.html


While I’ll still plan on sharing my experiences with Sketchflow, I’ll never trick anyone into thinking I’m capable of authoring an authoritative manual that breaks some fairly technical things down into easy to follow steps in natural language. Chris and Sara do a fine job of that though, so if any of this Sketchflow chatter is even remotely intriguing, go
When we first heard about Silverlight 3, it was Sketchflow that got the oohs and ahhs around the office. If you haven’t seen it yet, Brad gives
The “boxes and arrows” live in the asset pile on the left. The Map at the bottom let’s you easily create new pages, and the main workspace lets you layout the page you’ve selected. (Tip: you can define the default SketchFlow page document size in the settings). You can safely ignore most of the rest of the app and use these core panels to get your simple IA projects underway.
From this point on, it’s pretty intuitive to drag and drop your boxes around on the page. An advantage over Illustrator is that said boxes can have unique properties. If you grab the password box, it will give you a rectangular input box filled with obscured characters, the progress bar box lets you define the visible percentage (I assume if you are doing an animated walkthrough, it can be fully functional, but again, not necessary for my semi-flat low fi wires). And of course, the button boxes predictably enough act as buttons- click on them to rename them, and right/ctrl click them to tell them which page in the SketchFlow map they should carry the user to on a click. Easy enough to figure out without having to resort to documentation!
Pretty cool. Again, there are resources out there for the hardcore nerds that want to build detailed UX prototypes. But it’s simple enough to pickup the basics that even casual IA dabblers should think about giving it a spin instead of spitting PDFs out of Illustrator.