Happy new year!
[macgdbp.git] / Source / GDBpConnection.m
1 /*
2 * MacGDBp
3 * Copyright (c) 2007 - 2010, Blue Static <http://www.bluestatic.org>
4 *
5 * This program is free software; you can redistribute it and/or modify it under the terms of the GNU
6 * General Public License as published by the Free Software Foundation; either version 2 of the
7 * License, or (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
10 * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
11 * General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along with this program; if not,
14 * write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
15 */
16
17 #import <sys/socket.h>
18 #import <netinet/in.h>
19
20 #import "GDBpConnection.h"
21
22 #import "AppDelegate.h"
23
24 // GDBpConnection (Private) ////////////////////////////////////////////////////
25
26 @interface GDBpConnection ()
27 @property(readwrite, copy) NSString* status;
28 @property (assign) CFSocketRef socket;
29 @property (assign) CFReadStreamRef readStream;
30 @property (retain) NSMutableString* currentPacket;
31 @property (assign) CFWriteStreamRef writeStream;
32 @property (retain) NSMutableArray* queuedWrites;
33
34 - (void)connect;
35 - (void)close;
36 - (void)socketDidAccept;
37 - (void)socketDisconnected;
38 - (void)readStreamHasData;
39 - (void)send:(NSString*)command;
40 - (void)performSend:(NSString*)command;
41 - (void)errorEncountered:(NSString*)error;
42
43 - (void)handleResponse:(NSXMLDocument*)response;
44 - (void)initReceived:(NSXMLDocument*)response;
45 - (void)updateStatus:(NSXMLDocument*)response;
46 - (void)debuggerStep:(NSXMLDocument*)response;
47
48 - (NSString*)createCommand:(NSString*)cmd, ...;
49 - (StackFrame*)createStackFrame:(int)depth;
50 - (StackFrame*)createCurrentStackFrame;
51 - (NSString*)escapedURIPath:(NSString*)path;
52 @end
53
54 // CFNetwork Callbacks /////////////////////////////////////////////////////////
55
56 void ReadStreamCallback(CFReadStreamRef stream, CFStreamEventType eventType, void* connectionRaw)
57 {
58 NSLog(@"ReadStreamCallback()");
59 GDBpConnection* connection = (GDBpConnection*)connectionRaw;
60 switch (eventType)
61 {
62 case kCFStreamEventHasBytesAvailable:
63 [connection readStreamHasData];
64 break;
65
66 case kCFStreamEventErrorOccurred:
67 {
68 CFErrorRef error = CFReadStreamCopyError(stream);
69 CFReadStreamUnscheduleFromRunLoop(stream, CFRunLoopGetCurrent(), kCFRunLoopCommonModes);
70 CFReadStreamClose(stream);
71 CFRelease(stream);
72 [connection errorEncountered:[[(NSError*)error autorelease] description]];
73 break;
74 }
75
76 case kCFStreamEventEndEncountered:
77 CFReadStreamUnscheduleFromRunLoop(stream, CFRunLoopGetCurrent(), kCFRunLoopCommonModes);
78 CFReadStreamClose(stream);
79 CFRelease(stream);
80 [connection socketDisconnected];
81 break;
82 };
83 }
84
85 void WriteStreamCallback(CFWriteStreamRef stream, CFStreamEventType eventType, void* connectionRaw)
86 {
87 NSLog(@"WriteStreamCallback()");
88 GDBpConnection* connection = (GDBpConnection*)connectionRaw;
89 switch (eventType)
90 {
91 case kCFStreamEventCanAcceptBytes:
92 NSLog(@"can accept bytes");
93 if ([connection.queuedWrites count] > 0)
94 {
95 NSString* command = [connection.queuedWrites objectAtIndex:0];
96 [connection performSend:command];
97 [connection.queuedWrites removeObjectAtIndex:0];
98 }
99 break;
100
101 case kCFStreamEventErrorOccurred:
102 {
103 CFErrorRef error = CFWriteStreamCopyError(stream);
104 CFWriteStreamUnscheduleFromRunLoop(stream, CFRunLoopGetCurrent(), kCFRunLoopCommonModes);
105 CFWriteStreamClose(stream);
106 CFRelease(stream);
107 [connection errorEncountered:[[(NSError*)error autorelease] description]];
108 break;
109 }
110
111 case kCFStreamEventEndEncountered:
112 CFWriteStreamUnscheduleFromRunLoop(stream, CFRunLoopGetCurrent(), kCFRunLoopCommonModes);
113 CFWriteStreamClose(stream);
114 CFRelease(stream);
115 [connection socketDisconnected];
116 break;
117 }
118 }
119
120 void SocketAcceptCallback(CFSocketRef socket,
121 CFSocketCallBackType callbackType,
122 CFDataRef address,
123 const void* data,
124 void* connectionRaw)
125 {
126 assert(callbackType == kCFSocketAcceptCallBack);
127 NSLog(@"SocketAcceptCallback()");
128
129 GDBpConnection* connection = (GDBpConnection*)connectionRaw;
130
131 CFReadStreamRef readStream;
132 CFWriteStreamRef writeStream;
133
134 // Create the streams on the socket.
135 CFStreamCreatePairWithSocket(kCFAllocatorDefault,
136 *(CFSocketNativeHandle*)data, // Socket handle.
137 &readStream, // Read stream in-pointer.
138 &writeStream); // Write stream in-pointer.
139
140 // Create struct to register callbacks for the stream.
141 CFStreamClientContext context;
142 context.version = 0;
143 context.info = connection;
144 context.retain = NULL;
145 context.release = NULL;
146 context.copyDescription = NULL;
147
148 // Set the client of the read stream.
149 CFOptionFlags readFlags =
150 kCFStreamEventOpenCompleted |
151 kCFStreamEventHasBytesAvailable |
152 kCFStreamEventErrorOccurred |
153 kCFStreamEventEndEncountered;
154 if (CFReadStreamSetClient(readStream, readFlags, ReadStreamCallback, &context))
155 // Schedule in run loop to do asynchronous communication with the engine.
156 CFReadStreamScheduleWithRunLoop(readStream, CFRunLoopGetCurrent(), kCFRunLoopCommonModes);
157 else
158 return;
159
160 NSLog(@"Read stream scheduled");
161
162 // Open the stream now that it's scheduled on the run loop.
163 if (!CFReadStreamOpen(readStream))
164 {
165 CFStreamError error = CFReadStreamGetError(readStream);
166 NSLog(@"error! %@", error);
167 return;
168 }
169
170 NSLog(@"Read stream opened");
171
172 // Set the client of the write stream.
173 CFOptionFlags writeFlags =
174 kCFStreamEventOpenCompleted |
175 kCFStreamEventCanAcceptBytes |
176 kCFStreamEventErrorOccurred |
177 kCFStreamEventEndEncountered;
178 if (CFWriteStreamSetClient(writeStream, writeFlags, WriteStreamCallback, &context))
179 // Schedule it in the run loop to receive error information.
180 CFWriteStreamScheduleWithRunLoop(writeStream, CFRunLoopGetCurrent(), kCFRunLoopCommonModes);
181 else
182 return;
183
184 NSLog(@"Write stream scheduled");
185
186 // Open the write stream.
187 if (!CFWriteStreamOpen(writeStream))
188 {
189 CFStreamError error = CFWriteStreamGetError(writeStream);
190 NSLog(@"error! %@", error);
191 return;
192 }
193
194 NSLog(@"Write stream opened");
195
196 connection.readStream = readStream;
197 connection.writeStream = writeStream;
198 [connection socketDidAccept];
199 }
200
201 // GDBpConnection //////////////////////////////////////////////////////////////
202
203 @implementation GDBpConnection
204 @synthesize socket = socket_;
205 @synthesize readStream = readStream_;
206 @synthesize currentPacket = currentPacket_;
207 @synthesize writeStream = writeStream_;
208 @synthesize queuedWrites = queuedWrites_;
209 @synthesize status;
210 @synthesize delegate;
211
212 /**
213 * Creates a new DebuggerConnection and initializes the socket from the given connection
214 * paramters.
215 */
216 - (id)initWithPort:(int)aPort
217 {
218 if (self = [super init])
219 {
220 port = aPort;
221 connected = NO;
222
223 [[BreakpointManager sharedManager] setConnection:self];
224
225 [self connect];
226 }
227 return self;
228 }
229
230 /**
231 * Deallocates the object
232 */
233 - (void)dealloc
234 {
235 [self close];
236 self.currentPacket = nil;
237
238 [super dealloc];
239 }
240
241 /**
242 * Gets the port number
243 */
244 - (int)port
245 {
246 return port;
247 }
248
249 /**
250 * Returns the name of the remote host
251 */
252 - (NSString*)remoteHost
253 {
254 if (!connected)
255 {
256 return @"(DISCONNECTED)";
257 }
258 // TODO: Either impl or remove.
259 return @"";
260 }
261
262 /**
263 * Returns whether or not we have an active connection
264 */
265 - (BOOL)isConnected
266 {
267 return connected;
268 }
269
270 /**
271 * Called by SocketWrapper after the connection is successful. This immediately calls
272 * -[SocketWrapper receive] to clear the way for communication, though the information
273 * could be useful server information that we don't use right now.
274 */
275 - (void)socketDidAccept
276 {
277 connected = YES;
278 transactionID = 0;
279 }
280
281 /**
282 * Receives errors from the SocketWrapper and updates the display
283 */
284 - (void)errorEncountered:(NSString*)error
285 {
286 [delegate errorEncountered:error];
287 }
288
289 /**
290 * Reestablishes communication with the remote debugger so that a new connection doesn't have to be
291 * created every time you want to debug a page
292 */
293 - (void)reconnect
294 {
295 [self close];
296 self.status = @"Connecting";
297 [self connect];
298 }
299
300 /**
301 * Creates an entirely new stack and returns it as an array of StackFrame objects.
302 */
303 - (NSArray*)getCurrentStack
304 {
305 // get the total stack depth
306 [socket send:[self createCommand:@"stack_depth"]];
307 NSXMLDocument* doc = [self processData:[socket receive]];
308 int depth = [[[[doc rootElement] attributeForName:@"depth"] stringValue] intValue];
309
310 // get all stack frames
311 NSMutableArray* stack = [NSMutableArray arrayWithCapacity:depth];
312 for (int i = 0; i < depth; i++)
313 {
314 StackFrame* frame = [self createStackFrame:i];
315 [stack insertObject:frame atIndex:i];
316 }
317
318 return stack;
319 }
320
321 /**
322 * Tells the debugger to continue running the script. Returns the current stack frame.
323 */
324 - (void)run
325 {
326 [self send:[self createCommand:@"run"]];
327 }
328
329 /**
330 * Tells the debugger to step into the current command.
331 */
332 - (void)stepIn
333 {
334 [self send:[self createCommand:@"step_into"]];
335 }
336
337 /**
338 * Tells the debugger to step out of the current context
339 */
340 - (void)stepOut
341 {
342 [self send:[self createCommand:@"step_out"]];
343 }
344
345 /**
346 * Tells the debugger to step over the current function
347 */
348 - (void)stepOver
349 {
350 [self send:[self createCommand:@"step_over"]];
351 }
352
353 /**
354 * Tells the debugger engine to get a specifc property. This also takes in the NSXMLElement
355 * that requested it so that the child can be attached.
356 */
357 - (NSArray*)getProperty:(NSString*)property
358 {
359 [socket send:[self createCommand:[NSString stringWithFormat:@"property_get -n \"%@\"", property]]];
360
361 NSXMLDocument* doc = [self processData:[socket receive]];
362
363 /*
364 <response>
365 <property> <!-- this is the one we requested -->
366 <property ... /> <!-- these are what we want -->
367 </property>
368 </repsonse>
369 */
370
371 // we now have to detach all the children so we can insert them into another document
372 NSXMLElement* parent = (NSXMLElement*)[[doc rootElement] childAtIndex:0];
373 NSArray* children = [parent children];
374 [parent setChildren:nil];
375 return children;
376 }
377
378 #pragma mark Breakpoints
379
380 /**
381 * Send an add breakpoint command
382 */
383 - (void)addBreakpoint:(Breakpoint*)bp
384 {
385 if (!connected)
386 return;
387
388 NSString* file = [self escapedURIPath:[bp transformedPath]];
389 NSString* cmd = [self createCommand:[NSString stringWithFormat:@"breakpoint_set -t line -f %@ -n %i", file, [bp line]]];
390 [socket send:cmd];
391 NSXMLDocument* info = [self processData:[socket receive]];
392 [bp setDebuggerId:[[[[info rootElement] attributeForName:@"id"] stringValue] intValue]];
393 }
394
395 /**
396 * Removes a breakpoint
397 */
398 - (void)removeBreakpoint:(Breakpoint*)bp
399 {
400 if (!connected)
401 {
402 return;
403 }
404
405 [socket send:[self createCommand:[NSString stringWithFormat:@"breakpoint_remove -d %i", [bp debuggerId]]]];
406 [socket receive];
407 }
408
409 #pragma mark Socket and Stream Callbacks
410
411 /**
412 * Creates, connects to, and schedules a CFSocket.
413 */
414 - (void)connect
415 {
416 // Pass ourselves to the callback so we don't have to use ugly globals.
417 CFSocketContext context;
418 context.version = 0;
419 context.info = self;
420 context.retain = NULL;
421 context.release = NULL;
422 context.copyDescription = NULL;
423
424 // Create the address structure.
425 struct sockaddr_in address;
426 memset(&address, 0, sizeof(address));
427 address.sin_len = sizeof(address);
428 address.sin_family = AF_INET;
429 address.sin_port = htons(port);
430 address.sin_addr.s_addr = htonl(INADDR_ANY);
431
432 // Create the socket signature.
433 CFSocketSignature signature;
434 signature.protocolFamily = PF_INET;
435 signature.socketType = SOCK_STREAM;
436 signature.protocol = IPPROTO_TCP;
437 signature.address = (CFDataRef)[NSData dataWithBytes:&address length:sizeof(address)];
438
439 socket_ = CFSocketCreateWithSocketSignature(kCFAllocatorDefault,
440 &signature, // Socket signature.
441 kCFSocketAcceptCallBack, // Callback types.
442 SocketAcceptCallback, // Callout function pointer.
443 &context); // Context to pass to callout.
444 if (!socket_)
445 {
446 [self errorEncountered:@"Could not open socket."];
447 return;
448 }
449
450 // Allow old, yet-to-be recycled sockets to be reused.
451 BOOL yes = YES;
452 setsockopt(CFSocketGetNative(socket_), SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(BOOL));
453
454 // Schedule the socket on the run loop.
455 CFRunLoopSourceRef source = CFSocketCreateRunLoopSource(kCFAllocatorDefault, socket_, 0);
456 CFRunLoopAddSource(CFRunLoopGetCurrent(), source, kCFRunLoopCommonModes);
457 CFRelease(source);
458
459 self.status = @"Connecting";
460 }
461
462 /**
463 * Closes a socket and releases the ref.
464 */
465 - (void)close
466 {
467 // The socket goes down, so do the streams, which clean themselves up.
468 CFSocketInvalidate(socket_);
469 CFRelease(socket_);
470 }
471
472 /**
473 * Notification that the socket disconnected.
474 */
475 - (void)socketDisconnected
476 {
477 [self close];
478 [delegate debuggerDisconnected];
479 }
480
481 /**
482 * Callback from the CFReadStream that there is data waiting to be read.
483 */
484 - (void)readStreamHasData
485 {
486 UInt8 buffer[1024];
487 CFIndex bytesRead = CFReadStreamRead(readStream_, buffer, 1024);
488 const char* charBuffer = (const char*)buffer;
489
490 // We haven't finished reading a packet, so just read more data in.
491 if (currentPacketIndex_ < packetSize_)
492 {
493 [currentPacket_ appendFormat:@"%s", buffer];
494 currentPacketIndex_ += bytesRead;
495 }
496 // Time to read a new packet.
497 else
498 {
499 // Read the message header: the size.
500 packetSize_ = atoi(charBuffer);
501 currentPacketIndex_ = bytesRead - strlen(charBuffer);
502 self.currentPacket = [NSMutableString stringWithFormat:@"%s", buffer + strlen(charBuffer) + 1];
503 }
504
505 // We have finished reading the packet.
506 if (currentPacketIndex_ >= packetSize_)
507 {
508 packetSize_ = 0;
509 currentPacketIndex_ = 0;
510
511 // Test if we can convert it into an NSXMLDocument.
512 NSError* error = nil;
513 NSXMLDocument* xmlTest = [[NSXMLDocument alloc] initWithXMLString:currentPacket_ options:NSXMLDocumentTidyXML error:&error];
514 if (error)
515 {
516 NSLog(@"Could not parse XML? --- %@", error);
517 NSLog(@"Error UserInfo: %@", [error userInfo]);
518 NSLog(@"This is the XML Document: %@", currentPacket_);
519 return;
520 }
521 [self handleResponse:[xmlTest autorelease]];
522 }
523 }
524
525 /**
526 * Writes a command into the write stream. If the stream is ready for writing,
527 * we do so immediately. If not, the command is queued and will be written
528 * when the stream is ready.
529 */
530 - (void)send:(NSString*)command
531 {
532 if (CFWriteStreamCanAcceptBytes(writeStream_))
533 [self performSend:command];
534 else
535 [queuedWrites_ addObject:command];
536 }
537
538 /**
539 * This performs a blocking send. This should ONLY be called when we know we
540 * have write access to the stream. We will busy wait in case we don't do a full
541 * send.
542 */
543 - (void)performSend:(NSString*)command
544 {
545 BOOL done = NO;
546
547 char* string = (char*)[command UTF8String];
548 int stringLength = strlen(string);
549
550 // Busy wait while writing. BAADD. Should background this operation.
551 while (!done)
552 {
553 if (CFWriteStreamCanAcceptBytes(writeStream_))
554 {
555 // Include the NULL byte in the string when we write.
556 int bytesWritten = CFWriteStreamWrite(writeStream_, (UInt8*)string, stringLength + 1);
557 if (bytesWritten < 0)
558 {
559 NSLog(@"write error");
560 }
561 // Incomplete write.
562 else if (bytesWritten < strlen(string))
563 {
564 // Adjust the buffer and wait for another chance to write.
565 stringLength -= bytesWritten;
566 memmove(string, string + bytesWritten, stringLength);
567 }
568 else
569 {
570 done = YES;
571 }
572 }
573 }
574 }
575
576 #pragma mark Response Handlers
577
578 - (void)handleResponse:(NSXMLDocument*)response
579 {
580 // Check and see if there's an error.
581 NSArray* error = [[response rootElement] elementsForName:@"error"];
582 if ([error count] > 0)
583 {
584 NSLog(@"Xdebug error: %@", error);
585 [delegate errorEncountered:[[[[error objectAtIndex:0] children] objectAtIndex:0] stringValue]];
586 }
587
588 // If TransportDebug is enabled, log the response.
589 if ([[[[NSProcessInfo processInfo] environment] objectForKey:@"TransportDebug"] boolValue])
590 NSLog(@"<-- %@", response);
591
592 // Get the name of the command from the engine's response.
593 NSString* command = [[[response rootElement] attributeForName:@"command"] stringValue];
594
595 // Dispatch the command response to an appropriate handler.
596 if ([[[response rootElement] name] isEqualToString:@"init"])
597 [self initReceived:response];
598 else if ([command isEqualToString:@"status"])
599 [self updateStatus:response];
600 else if ([command isEqualToString:@"run"] || [command isEqualToString:@"step_into"] ||
601 [command isEqualToString:@"step_over"] || [command isEqualToString:@"step_out"])
602 [self debuggerStep:response];
603 }
604
605 - (void)initReceived:(NSXMLDocument*)response
606 {
607 // Register any breakpoints that exist offline.
608 for (Breakpoint* bp in [[BreakpointManager sharedManager] breakpoints])
609 [self addBreakpoint:bp];
610
611 // Load the debugger to make it look active.
612 [delegate debuggerConnected];
613
614 [self send:[self createCommand:@"status"]];
615 }
616
617 /**
618 * Fetches the value of and sets the status instance variable
619 */
620 - (void)updateStatus:(NSXMLDocument*)response
621 {
622 self.status = [[[[response rootElement] attributeForName:@"status"] stringValue] capitalizedString];
623 if (status == nil || [status isEqualToString:@"Stopped"] || [status isEqualToString:@"Stopping"])
624 {
625 connected = NO;
626 [self close];
627 [delegate debuggerDisconnected];
628
629 self.status = @"Stopped";
630 }
631 }
632
633 - (void)debuggerStep:(NSXMLDocument*)response
634 {
635 [self send:[self createCommand:@"status"]];
636 }
637
638 #pragma mark Private
639
640 /**
641 * Helper method to create a string command with the -i <transaction id> automatically tacked on. Takes
642 * a variable number of arguments and parses the given command with +[NSString stringWithFormat:]
643 */
644 - (NSString*)createCommand:(NSString*)cmd, ...
645 {
646 // collect varargs
647 va_list argList;
648 va_start(argList, cmd);
649 NSString* format = [[NSString alloc] initWithFormat:cmd arguments:argList]; // format the command
650 va_end(argList);
651
652 if ([[[[NSProcessInfo processInfo] environment] objectForKey:@"TransportDebug"] boolValue])
653 NSLog(@"--> %@", cmd);
654
655 return [NSString stringWithFormat:@"%@ -i %d", [format autorelease], transactionID++];
656 }
657
658 /**
659 * Generates a stack frame for the given depth
660 */
661 - (StackFrame*)createStackFrame:(int)stackDepth
662 {
663 // get the stack frame
664 [socket send:[self createCommand:@"stack_get -d %d", stackDepth]];
665 NSXMLDocument* doc = [self processData:[socket receive]];
666 if (doc == nil)
667 return nil;
668
669 NSXMLElement* xmlframe = [[[doc rootElement] children] objectAtIndex:0];
670
671 // get the names of all the contexts
672 [socket send:[self createCommand:@"context_names -d 0"]];
673 NSXMLElement* contextNames = [[self processData:[socket receive]] rootElement];
674 NSMutableArray* variables = [NSMutableArray array];
675 for (NSXMLElement* context in [contextNames children])
676 {
677 NSString* name = [[context attributeForName:@"name"] stringValue];
678 int cid = [[[context attributeForName:@"id"] stringValue] intValue];
679
680 // fetch the contexts
681 [socket send:[self createCommand:[NSString stringWithFormat:@"context_get -d %d -c %d", stackDepth, cid]]];
682 NSArray* addVars = [[[self processData:[socket receive]] rootElement] children];
683 if (addVars != nil && name != nil)
684 [variables addObjectsFromArray:addVars];
685 }
686
687 // get the source
688 NSString* filename = [[xmlframe attributeForName:@"filename"] stringValue];
689 NSString* escapedFilename = [filename stringByReplacingOccurrencesOfString:@"%" withString:@"%%"]; // escape % in URL chars
690 [socket send:[self createCommand:[NSString stringWithFormat:@"source -f %@", escapedFilename]]];
691 NSString* source = [[[self processData:[socket receive]] rootElement] value]; // decode base64
692
693 // create stack frame
694 StackFrame* frame = [[StackFrame alloc]
695 initWithIndex:stackDepth
696 withFilename:filename
697 withSource:source
698 atLine:[[[xmlframe attributeForName:@"lineno"] stringValue] intValue]
699 inFunction:[[xmlframe attributeForName:@"where"] stringValue]
700 withVariables:variables
701 ];
702
703 return [frame autorelease];
704 }
705
706 /**
707 * Creates a StackFrame based on the current position in the debugger
708 */
709 - (StackFrame*)createCurrentStackFrame
710 {
711 return [self createStackFrame:0];
712 }
713
714 /**
715 * Given a file path, this returns a file:// URI and escapes any spaces for the
716 * debugger engine.
717 */
718 - (NSString*)escapedURIPath:(NSString*)path
719 {
720 // Custon GDBp paths are fine.
721 if ([[path substringToIndex:4] isEqualToString:@"gdbp"])
722 return path;
723
724 // Create a temporary URL that will escape all the nasty characters.
725 NSURL* url = [NSURL fileURLWithPath:path];
726 NSString* urlString = [url absoluteString];
727
728 // Remove the host because this is a file:// URL;
729 urlString = [urlString stringByReplacingOccurrencesOfString:[url host] withString:@""];
730
731 // Escape % for use in printf-style NSString formatters.
732 urlString = [urlString stringByReplacingOccurrencesOfString:@"%" withString:@"%%"];
733 return urlString;
734 }
735
736 @end