Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(497)

Side by Side Diff: chrome/browser/ui/cocoa/extensions/extension_installed_bubble_controller.mm

Issue 9460045: Add Mac interface for installing bundles of extensions. (Closed) Base URL: svn://svn.chromium.org/chrome/trunk/src
Patch Set: Created 8 years, 9 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
OLDNEW
1 // Copyright (c) 2012 The Chromium Authors. All rights reserved. 1 // Copyright (c) 2012 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be 2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file. 3 // found in the LICENSE file.
4 4
5 #import "chrome/browser/ui/cocoa/extensions/extension_installed_bubble_controlle r.h" 5 #import "chrome/browser/ui/cocoa/extensions/extension_installed_bubble_controlle r.h"
6 6
7 #include "base/i18n/rtl.h" 7 #include "base/i18n/rtl.h"
8 #include "base/mac/bundle_locations.h" 8 #include "base/mac/bundle_locations.h"
9 #include "base/mac/mac_util.h" 9 #include "base/mac/mac_util.h"
10 #include "base/sys_string_conversions.h" 10 #include "base/sys_string_conversions.h"
11 #include "base/utf_string_conversions.h" 11 #include "base/utf_string_conversions.h"
12 #include "chrome/browser/extensions/bundle_installer.h"
12 #include "chrome/browser/ui/browser.h" 13 #include "chrome/browser/ui/browser.h"
13 #include "chrome/browser/ui/browser_window.h" 14 #include "chrome/browser/ui/browser_window.h"
14 #include "chrome/browser/ui/cocoa/browser_window_cocoa.h" 15 #include "chrome/browser/ui/cocoa/browser_window_cocoa.h"
15 #include "chrome/browser/ui/cocoa/browser_window_controller.h" 16 #include "chrome/browser/ui/cocoa/browser_window_controller.h"
16 #include "chrome/browser/ui/cocoa/extensions/browser_actions_controller.h" 17 #include "chrome/browser/ui/cocoa/extensions/browser_actions_controller.h"
17 #include "chrome/browser/ui/cocoa/hover_close_button.h" 18 #include "chrome/browser/ui/cocoa/hover_close_button.h"
18 #include "chrome/browser/ui/cocoa/info_bubble_view.h" 19 #include "chrome/browser/ui/cocoa/info_bubble_view.h"
19 #include "chrome/browser/ui/cocoa/location_bar/location_bar_view_mac.h" 20 #include "chrome/browser/ui/cocoa/location_bar/location_bar_view_mac.h"
20 #include "chrome/browser/ui/cocoa/toolbar/toolbar_controller.h" 21 #include "chrome/browser/ui/cocoa/toolbar/toolbar_controller.h"
21 #include "chrome/common/chrome_notification_types.h" 22 #include "chrome/common/chrome_notification_types.h"
22 #include "chrome/common/extensions/extension.h" 23 #include "chrome/common/extensions/extension.h"
23 #include "chrome/common/extensions/extension_action.h" 24 #include "chrome/common/extensions/extension_action.h"
24 #include "content/public/browser/notification_details.h" 25 #include "content/public/browser/notification_details.h"
25 #include "content/public/browser/notification_registrar.h" 26 #include "content/public/browser/notification_registrar.h"
26 #include "content/public/browser/notification_source.h" 27 #include "content/public/browser/notification_source.h"
27 #include "grit/chromium_strings.h" 28 #include "grit/chromium_strings.h"
28 #include "grit/generated_resources.h" 29 #include "grit/generated_resources.h"
29 #import "skia/ext/skia_utils_mac.h" 30 #import "skia/ext/skia_utils_mac.h"
30 #import "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" 31 #import "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h"
31 #include "ui/base/l10n/l10n_util.h" 32 #include "ui/base/l10n/l10n_util.h"
32 33
33 using content::BrowserThread; 34 using content::BrowserThread;
35 using extensions::BundleInstaller;
34 36
35 // C++ class that receives EXTENSION_LOADED notifications and proxies them back 37 // C++ class that receives EXTENSION_LOADED notifications and proxies them back
36 // to |controller|. 38 // to |controller|.
37 class ExtensionLoadedNotificationObserver 39 class ExtensionLoadedNotificationObserver
38 : public content::NotificationObserver { 40 : public content::NotificationObserver {
39 public: 41 public:
40 ExtensionLoadedNotificationObserver( 42 ExtensionLoadedNotificationObserver(
41 ExtensionInstalledBubbleController* controller, Profile* profile) 43 ExtensionInstalledBubbleController* controller, Profile* profile)
42 : controller_(controller) { 44 : controller_(controller) {
43 registrar_.Add(this, chrome::NOTIFICATION_EXTENSION_LOADED, 45 registrar_.Add(this, chrome::NOTIFICATION_EXTENSION_LOADED,
(...skipping 29 matching lines...) Expand all
73 } 75 }
74 } 76 }
75 77
76 content::NotificationRegistrar registrar_; 78 content::NotificationRegistrar registrar_;
77 ExtensionInstalledBubbleController* controller_; // weak, owns us 79 ExtensionInstalledBubbleController* controller_; // weak, owns us
78 }; 80 };
79 81
80 @implementation ExtensionInstalledBubbleController 82 @implementation ExtensionInstalledBubbleController
81 83
82 @synthesize extension = extension_; 84 @synthesize extension = extension_;
85 @synthesize bundle = bundle_;
83 @synthesize pageActionRemoved = pageActionRemoved_; // Exposed for unit test. 86 @synthesize pageActionRemoved = pageActionRemoved_; // Exposed for unit test.
84 87
85 - (id)initWithParentWindow:(NSWindow*)parentWindow 88 - (id)initWithParentWindow:(NSWindow*)parentWindow
86 extension:(const Extension*)extension 89 extension:(const Extension*)extension
90 bundle:(const BundleInstaller*)bundle
87 browser:(Browser*)browser 91 browser:(Browser*)browser
88 icon:(SkBitmap)icon { 92 icon:(SkBitmap)icon {
89 NSString* nibPath = 93 NSString* nibName = bundle ? @"ExtensionInstalledBubbleBundle" :
90 [base::mac::FrameworkBundle() pathForResource:@"ExtensionInstalledBubble" 94 @"ExtensionInstalledBubble";
91 ofType:@"nib"]; 95 NSString* nibPath = [base::mac::FrameworkBundle() pathForResource:nibName
96 ofType:@"nib"];
92 if ((self = [super initWithWindowNibPath:nibPath owner:self])) { 97 if ((self = [super initWithWindowNibPath:nibPath owner:self])) {
93 DCHECK(parentWindow); 98 DCHECK(parentWindow);
94 parentWindow_ = parentWindow; 99 parentWindow_ = parentWindow;
95 DCHECK(extension);
96 extension_ = extension; 100 extension_ = extension;
101 bundle_ = bundle;
97 DCHECK(browser); 102 DCHECK(browser);
98 browser_ = browser; 103 browser_ = browser;
99 icon_.reset([gfx::SkBitmapToNSImage(icon) retain]); 104 icon_.reset([gfx::SkBitmapToNSImage(icon) retain]);
100 pageActionRemoved_ = NO; 105 pageActionRemoved_ = NO;
101 106
102 if (!extension->omnibox_keyword().empty()) { 107 if (bundle_) {
108 type_ = extension_installed_bubble::kBundle;
109 } else if (!extension->omnibox_keyword().empty()) {
103 type_ = extension_installed_bubble::kOmniboxKeyword; 110 type_ = extension_installed_bubble::kOmniboxKeyword;
104 } else if (extension->browser_action()) { 111 } else if (extension->browser_action()) {
105 type_ = extension_installed_bubble::kBrowserAction; 112 type_ = extension_installed_bubble::kBrowserAction;
106 } else if (extension->page_action() && 113 } else if (extension->page_action() &&
107 !extension->page_action()->default_icon_path().empty()) { 114 !extension->page_action()->default_icon_path().empty()) {
108 type_ = extension_installed_bubble::kPageAction; 115 type_ = extension_installed_bubble::kPageAction;
109 } else { 116 } else {
110 NOTREACHED(); // kGeneric installs handled in the extension_install_ui. 117 NOTREACHED(); // kGeneric installs handled in the extension_install_ui.
111 } 118 }
112 119
113 // Start showing window only after extension has fully loaded. 120 if (type_ == extension_installed_bubble::kBundle) {
114 extensionObserver_.reset(new ExtensionLoadedNotificationObserver( 121 [self showWindow:self];
115 self, browser->profile())); 122 } else {
123 // Start showing window only after extension has fully loaded.
124 extensionObserver_.reset(new ExtensionLoadedNotificationObserver(
125 self, browser->profile()));
126 }
116 127
117 // Watch to see if the parent window closes, and if so, close this one. 128 // Watch to see if the parent window closes, and if so, close this one.
118 NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; 129 NSNotificationCenter* center = [NSNotificationCenter defaultCenter];
119 [center addObserver:self 130 [center addObserver:self
120 selector:@selector(parentWindowWillClose:) 131 selector:@selector(parentWindowWillClose:)
121 name:NSWindowWillCloseNotification 132 name:NSWindowWillCloseNotification
122 object:parentWindow_]; 133 object:parentWindow_];
123 } 134 }
124 return self; 135 return self;
125 } 136 }
(...skipping 91 matching lines...) Expand 10 before | Expand all | Expand 10 after
217 // would ordinarily only be displayed on a page of the appropriate type. 228 // would ordinarily only be displayed on a page of the appropriate type.
218 // We remove this preview when the extension installed bubble closes. 229 // We remove this preview when the extension installed bubble closes.
219 locationBarView->SetPreviewEnabledPageAction(extension_->page_action(), 230 locationBarView->SetPreviewEnabledPageAction(extension_->page_action(),
220 true); 231 true);
221 232
222 // Find the center of the bottom of the page action icon. 233 // Find the center of the bottom of the page action icon.
223 arrowPoint = 234 arrowPoint =
224 locationBarView->GetPageActionBubblePoint(extension_->page_action()); 235 locationBarView->GetPageActionBubblePoint(extension_->page_action());
225 break; 236 break;
226 } 237 }
238 case extension_installed_bubble::kBundle: {
239 NSView* wrenchButton =
240 [[window->cocoa_controller() toolbarController] wrenchButton];
241 const NSRect bounds = [wrenchButton bounds];
242 NSPoint anchor = NSMakePoint(NSMidX(bounds), NSMidY(bounds));
243 arrowPoint = [wrenchButton convertPoint:anchor toView:nil];
244 break;
245 }
227 default: { 246 default: {
228 NOTREACHED() << "Generic extension type not allowed in install bubble."; 247 NOTREACHED() << "Generic extension type not allowed in install bubble.";
229 } 248 }
230 } 249 }
231 return arrowPoint; 250 return arrowPoint;
232 } 251 }
233 252
234 // We want this to be a child of a browser window. addChildWindow: 253 // We want this to be a child of a browser window. addChildWindow:
235 // (called from this function) will bring the window on-screen; 254 // (called from this function) will bring the window on-screen;
236 // unfortunately, [NSWindowController showWindow:] will also bring it 255 // unfortunately, [NSWindowController showWindow:] will also bring it
(...skipping 40 matching lines...) Expand 10 before | Expand all | Expand 10 after
277 // function is exposed for unit testing. 296 // function is exposed for unit testing.
278 - (NSWindow*)initializeWindow { 297 - (NSWindow*)initializeWindow {
279 NSWindow* window = [self window]; // completes nib load 298 NSWindow* window = [self window]; // completes nib load
280 299
281 if (type_ == extension_installed_bubble::kOmniboxKeyword) { 300 if (type_ == extension_installed_bubble::kOmniboxKeyword) {
282 [infoBubbleView_ setArrowLocation:info_bubble::kTopLeft]; 301 [infoBubbleView_ setArrowLocation:info_bubble::kTopLeft];
283 } else { 302 } else {
284 [infoBubbleView_ setArrowLocation:info_bubble::kTopRight]; 303 [infoBubbleView_ setArrowLocation:info_bubble::kTopRight];
285 } 304 }
286 305
306 if (type_ == extension_installed_bubble::kBundle)
307 return window;
308
287 // Set appropriate icon, resizing if necessary. 309 // Set appropriate icon, resizing if necessary.
288 if ([icon_ size].width > extension_installed_bubble::kIconSize) { 310 if ([icon_ size].width > extension_installed_bubble::kIconSize) {
289 [icon_ setSize:NSMakeSize(extension_installed_bubble::kIconSize, 311 [icon_ setSize:NSMakeSize(extension_installed_bubble::kIconSize,
290 extension_installed_bubble::kIconSize)]; 312 extension_installed_bubble::kIconSize)];
291 } 313 }
292 [iconImage_ setImage:icon_]; 314 [iconImage_ setImage:icon_];
293 [iconImage_ setNeedsDisplay:YES]; 315 [iconImage_ setNeedsDisplay:YES];
294 return window; 316 return window;
295 } 317 }
296 318
297 // Calculate the height of each install message, resizing messages in their 319 // Calculate the height of each install message, resizing messages in their
298 // frames to fit window width. Return the new window height, based on the 320 // frames to fit window width. Return the new window height, based on the
299 // total of all message heights. 321 // total of all message heights.
300 - (int)calculateWindowHeight { 322 - (int)calculateWindowHeight {
301 // Adjust the window height to reflect the sum height of all messages 323 // Adjust the window height to reflect the sum height of all messages
302 // and vertical padding. 324 // and vertical padding.
303 int newWindowHeight = 2 * extension_installed_bubble::kOuterVerticalMargin; 325 int newWindowHeight = 2 * extension_installed_bubble::kOuterVerticalMargin;
304 326
305 // First part of extension installed message. 327 // First part of extension installed message.
306 string16 extension_name = UTF8ToUTF16(extension_->name().c_str()); 328 if (type_ != extension_installed_bubble::kBundle) {
307 base::i18n::AdjustStringForLocaleDirection(&extension_name); 329 string16 extension_name = UTF8ToUTF16(extension_->name().c_str());
308 [extensionInstalledMsg_ setStringValue:l10n_util::GetNSStringF( 330 base::i18n::AdjustStringForLocaleDirection(&extension_name);
309 IDS_EXTENSION_INSTALLED_HEADING, extension_name)]; 331 [extensionInstalledMsg_ setStringValue:l10n_util::GetNSStringF(
310 [GTMUILocalizerAndLayoutTweaker 332 IDS_EXTENSION_INSTALLED_HEADING, extension_name)];
333 [GTMUILocalizerAndLayoutTweaker
311 sizeToFitFixedWidthTextField:extensionInstalledMsg_]; 334 sizeToFitFixedWidthTextField:extensionInstalledMsg_];
312 newWindowHeight += [extensionInstalledMsg_ frame].size.height + 335 newWindowHeight += [extensionInstalledMsg_ frame].size.height +
313 extension_installed_bubble::kInnerVerticalMargin; 336 extension_installed_bubble::kInnerVerticalMargin;
337 }
314 338
315 // If type is page action, include a special message about page actions. 339 // If type is page action, include a special message about page actions.
316 if (type_ == extension_installed_bubble::kPageAction) { 340 if (type_ == extension_installed_bubble::kPageAction) {
317 [extraInfoMsg_ setHidden:NO]; 341 [extraInfoMsg_ setHidden:NO];
318 [[extraInfoMsg_ cell] 342 [[extraInfoMsg_ cell]
319 setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]]; 343 setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]];
320 [GTMUILocalizerAndLayoutTweaker 344 [GTMUILocalizerAndLayoutTweaker
321 sizeToFitFixedWidthTextField:extraInfoMsg_]; 345 sizeToFitFixedWidthTextField:extraInfoMsg_];
322 newWindowHeight += [extraInfoMsg_ frame].size.height + 346 newWindowHeight += [extraInfoMsg_ frame].size.height +
323 extension_installed_bubble::kInnerVerticalMargin; 347 extension_installed_bubble::kInnerVerticalMargin;
324 } 348 }
325 349
326 // If type is omnibox keyword, include a special message about the keyword. 350 // If type is omnibox keyword, include a special message about the keyword.
327 if (type_ == extension_installed_bubble::kOmniboxKeyword) { 351 if (type_ == extension_installed_bubble::kOmniboxKeyword) {
328 [extraInfoMsg_ setStringValue:l10n_util::GetNSStringF( 352 [extraInfoMsg_ setStringValue:l10n_util::GetNSStringF(
329 IDS_EXTENSION_INSTALLED_OMNIBOX_KEYWORD_INFO, 353 IDS_EXTENSION_INSTALLED_OMNIBOX_KEYWORD_INFO,
330 UTF8ToUTF16(extension_->omnibox_keyword()))]; 354 UTF8ToUTF16(extension_->omnibox_keyword()))];
331 [extraInfoMsg_ setHidden:NO]; 355 [extraInfoMsg_ setHidden:NO];
332 [[extraInfoMsg_ cell] 356 [[extraInfoMsg_ cell]
333 setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]]; 357 setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]];
334 [GTMUILocalizerAndLayoutTweaker 358 [GTMUILocalizerAndLayoutTweaker
335 sizeToFitFixedWidthTextField:extraInfoMsg_]; 359 sizeToFitFixedWidthTextField:extraInfoMsg_];
336 newWindowHeight += [extraInfoMsg_ frame].size.height + 360 newWindowHeight += [extraInfoMsg_ frame].size.height +
337 extension_installed_bubble::kInnerVerticalMargin; 361 extension_installed_bubble::kInnerVerticalMargin;
338 } 362 }
339 363
340 // Second part of extension installed message. 364 // If type is bundle, list the extensions that were installed and those that
341 [[extensionInstalledInfoMsg_ cell] 365 // failed.
342 setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]]; 366 if (type_ == extension_installed_bubble::kBundle) {
343 [GTMUILocalizerAndLayoutTweaker 367 NSInteger installedListHeight =
344 sizeToFitFixedWidthTextField:extensionInstalledInfoMsg_]; 368 [self addExtensionList:installedHeadingMsg_
345 newWindowHeight += [extensionInstalledInfoMsg_ frame].size.height; 369 itemsMsg:installedItemsMsg_
370 state:BundleInstaller::Item::STATE_INSTALLED];
371
372 NSInteger failedListHeight =
373 [self addExtensionList:failedHeadingMsg_
374 itemsMsg:failedItemsMsg_
375 state:BundleInstaller::Item::STATE_FAILED];
376
377 newWindowHeight += installedListHeight + failedListHeight;
378
379 // Put some space between the lists if both are present.
380 if (installedListHeight > 0 && failedListHeight > 0)
381 newWindowHeight += extension_installed_bubble::kInnerVerticalMargin;
382 } else {
383 // Second part of extension installed message.
384 [[extensionInstalledInfoMsg_ cell]
385 setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]];
386 [GTMUILocalizerAndLayoutTweaker
387 sizeToFitFixedWidthTextField:extensionInstalledInfoMsg_];
388 newWindowHeight += [extensionInstalledInfoMsg_ frame].size.height;
389 }
346 390
347 return newWindowHeight; 391 return newWindowHeight;
348 } 392 }
349 393
394 - (NSInteger)addExtensionList:(NSTextField*)headingMsg
395 itemsMsg:(NSTextField*)itemsMsg
396 state:(BundleInstaller::Item::State)state {
397 string16 heading = bundle_->GetHeadingTextFor(state);
398 bool hidden = heading.empty();
399 [headingMsg setHidden:hidden];
400 [itemsMsg setHidden:hidden];
401 if (hidden)
402 return 0;
403
404 [headingMsg setStringValue:base::SysUTF16ToNSString(heading)];
405 [GTMUILocalizerAndLayoutTweaker sizeToFitFixedWidthTextField:headingMsg];
406
407 NSMutableString* joinedItems = [NSMutableString string];
408 BundleInstaller::ItemList items = bundle_->GetItemsWithState(state);
409 for (size_t i = 0; i < items.size(); ++i) {
410 if (i > 0)
411 [joinedItems appendString:@"\n"];
412 [joinedItems appendString:base::SysUTF16ToNSString(
413 items[i].GetNameForDisplay())];
414 }
415
416 [itemsMsg setStringValue:joinedItems];
417 [GTMUILocalizerAndLayoutTweaker sizeToFitFixedWidthTextField:itemsMsg];
418
419 return [headingMsg frame].size.height +
420 extension_installed_bubble::kInnerVerticalMargin +
421 [itemsMsg frame].size.height;
422 }
423
350 // Adjust y-position of messages to sit properly in new window height. 424 // Adjust y-position of messages to sit properly in new window height.
351 - (void)setMessageFrames:(int)newWindowHeight { 425 - (void)setMessageFrames:(int)newWindowHeight {
352 // The extension messages will always be shown. 426 if (type_ == extension_installed_bubble::kBundle) {
427 // Layout the messages from the bottom up.
428 NSTextField* msgs[] = { failedItemsMsg_, failedHeadingMsg_,
429 installedItemsMsg_, installedHeadingMsg_ };
430 NSInteger offsetFromBottom = 0;
431 BOOL isFirstVisible = YES;
432 for (size_t i = 0; i < arraysize(msgs); ++i) {
433 if ([msgs[i] isHidden])
434 continue;
435
436 NSRect frame = [msgs[i] frame];
437 NSInteger margin = isFirstVisible ?
438 extension_installed_bubble::kOuterVerticalMargin :
439 extension_installed_bubble::kInnerVerticalMargin;
440
441 frame.origin.y = offsetFromBottom + margin;
442 [msgs[i] setFrame:frame];
443 offsetFromBottom += frame.size.height + margin;
444
445 isFirstVisible = NO;
446 }
447
448 // Move the close button a bit to vertically align it with the heading.
449 NSInteger closeButtonFudge = 1;
450 NSRect frame = [closeButton_ frame];
451 frame.origin.y = newWindowHeight - (frame.size.height + closeButtonFudge +
452 extension_installed_bubble::kOuterVerticalMargin);
453 [closeButton_ setFrame:frame];
454
455 return;
456 }
457
353 NSRect extensionMessageFrame1 = [extensionInstalledMsg_ frame]; 458 NSRect extensionMessageFrame1 = [extensionInstalledMsg_ frame];
354 NSRect extensionMessageFrame2 = [extensionInstalledInfoMsg_ frame]; 459 NSRect extensionMessageFrame2 = [extensionInstalledInfoMsg_ frame];
355 460
356 extensionMessageFrame1.origin.y = newWindowHeight - ( 461 extensionMessageFrame1.origin.y = newWindowHeight - (
357 extensionMessageFrame1.size.height + 462 extensionMessageFrame1.size.height +
358 extension_installed_bubble::kOuterVerticalMargin); 463 extension_installed_bubble::kOuterVerticalMargin);
359 [extensionInstalledMsg_ setFrame:extensionMessageFrame1]; 464 [extensionInstalledMsg_ setFrame:extensionMessageFrame1];
360 if (type_ == extension_installed_bubble::kPageAction || 465 if (type_ == extension_installed_bubble::kPageAction ||
361 type_ == extension_installed_bubble::kOmniboxKeyword) { 466 type_ == extension_installed_bubble::kOmniboxKeyword) {
362 // The extra message is only shown when appropriate. 467 // The extra message is only shown when appropriate.
(...skipping 24 matching lines...) Expand all
387 492
388 - (NSRect)getExtensionInstalledInfoMsgFrame { 493 - (NSRect)getExtensionInstalledInfoMsgFrame {
389 return [extensionInstalledInfoMsg_ frame]; 494 return [extensionInstalledInfoMsg_ frame];
390 } 495 }
391 496
392 - (void)extensionUnloaded:(id)sender { 497 - (void)extensionUnloaded:(id)sender {
393 extension_ = NULL; 498 extension_ = NULL;
394 } 499 }
395 500
396 @end 501 @end
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698