MainWindowController.m
/* |
Copyright (C) 2015 Apple Inc. All Rights Reserved. |
See LICENSE.txt for this sample’s licensing information |
Abstract: |
This class provides the delegate/datasource for the NSOutlineView and NSPredicateEditor. |
In addition, it maintains all the controller logic for the user interface. |
*/ |
#import "MainWindowController.h" |
#import "SearchQuery.h" |
#import "SearchItem.h" |
@interface MainWindowController() <NSOutlineViewDelegate, NSOutlineViewDataSource, NSWindowDelegate, NSPathControlDelegate> |
@property (strong) NSMutableArray *iSearchQueries; |
@property (assign) CGFloat iThumbnailSize; |
@property (strong) NSURL *searchLocation; |
@property (weak) IBOutlet NSOutlineView *resultsOutlineView; |
@property (weak) IBOutlet NSPathControl *pathControl; // path control showing the search result item's location |
@property (weak) IBOutlet NSPredicateEditor *predicateEditor; |
@property (weak) IBOutlet NSWindow *window; |
@property (weak) IBOutlet NSPathControl *searchLocationPathControl; // path control determining the search location |
// outlet to the height constraint of the enclosing NSScrollView to the predicate editor, |
// each time a row is added or removed, we need to change this constraint value |
@property (weak) IBOutlet NSLayoutConstraint *heightConstraint; |
@property (strong) NSDateFormatter *dateFormatter; |
@end |
#pragma mark - |
@implementation MainWindowController |
@synthesize searchLocation = _searchLocation; |
#define COL_IMAGE_ID @"ImageID" // NSTableColumn identifier for column 1 (image + title + subtitle + info button) |
#define COL_CAMERA_MODEL_ID @"CameraModelID" // NSTableColumn identifier for column 2 (camera model) |
#define COL_LAST_MODIFIED_ID @"LastModifiedID" // NSTableColumn identifier for column 2 (date modified) |
#define VIEW_SEARCH_GROUP @"SearchGroup" // NSTableCellView identifier (group row view) |
#define Search_Location_Key @"searchLocationKey"// NSUserDefaults key for obtaining the security scoped bookmark search location |
#define Info_Button_Key @"InfoButtonKey" // key used to identify the info button in each view-row during mouse tracking |
#define SubTitle_Tag 1 // view tag for the sub title string |
#define InfoButton_Tag 2 // view tag for the info button |
#pragma mark - |
- (void)awakeFromNib { |
// note: since we are using view-based NSOutlineView, awakeFromNib is called each time we call makeViewWithIdentifier |
// and since this window controller is created as a top-level nib object, this is called at initialization time too, |
// so avoid this initialization later when table row views are created. |
// |
if (self.iSearchQueries == nil) { |
_iSearchQueries = [[NSMutableArray alloc] init]; |
_iThumbnailSize = 32.0; |
_dateFormatter = [[NSDateFormatter alloc] init]; |
self.dateFormatter.timeStyle = NSDateFormatterShortStyle; |
self.dateFormatter.dateStyle = NSDateFormatterMediumStyle; |
[[NSNotificationCenter defaultCenter] addObserver:self |
selector:@selector(queryChildrenChanged:) |
name:SearchQueryChildrenDidChangeNotification |
object:nil]; |
[[NSNotificationCenter defaultCenter] addObserver:self |
selector:@selector(searchItemChanged:) |
name:SearchItemDidChangeNotification |
object:nil]; |
[[NSNotificationCenter defaultCenter] addObserver:self |
selector:@selector(rowsDidChange:) |
name:NSRuleEditorRowsDidChangeNotification |
object:nil]; |
(self.resultsOutlineView).target = self; |
(self.resultsOutlineView).doubleAction = @selector(resultsOutlineDoubleClickAction:); |
NSString *placeHolderStr = NSLocalizedString(@"Select an item to show its location.", @"Placeholder string for location items"); |
[[self.pathControl cell] setPlaceholderString:placeHolderStr]; |
((NSPathCell *)(self.pathControl)).placeholderString = placeHolderStr; |
(self.pathControl).target = self; |
(self.pathControl).doubleAction = @selector(pathControlDoubleClick:); |
(self.predicateEditor).rowHeight = 25; |
// add some rows |
[(self.predicateEditor).enclosingScrollView setHasVerticalScroller:NO]; |
[self.predicateEditor addRow:self]; |
// put the focus in the text field |
id displayValue = [self.predicateEditor displayValuesForRow:1].lastObject; |
if ([displayValue isKindOfClass:[NSControl class]]) { |
[self.window makeFirstResponder:displayValue]; |
} |
[self updatePathControl]; |
// first look for the saved search location in NSUserDefaults |
NSError *error = nil; |
NSData *bookMarkDataToResolve = [[NSUserDefaults standardUserDefaults] objectForKey:Search_Location_Key]; |
if (bookMarkDataToResolve != nil) { |
// we have a saved bookmark from last time, so resolve the bookmark data into our NSURL |
self.searchLocation = [NSURL URLByResolvingBookmarkData:bookMarkDataToResolve |
options:NSURLBookmarkResolutionWithSecurityScope |
relativeToURL:nil |
bookmarkDataIsStale:nil |
error:&error]; |
} |
if (error != nil || self.searchLocation == nil) { |
// we could not find the default search location (error code could be NSFileNoSuchFileError) |
// meaning the search location last time no longer exists... |
// we don't have a default search location setup yet, so default our searchLocation |
// pointing to "Pictures" folder: |
// |
NSURL *picturesDirectoryURL = [[NSFileManager defaultManager] URLsForDirectory:NSPicturesDirectory |
inDomains:NSUserDomainMask].lastObject; |
// write out the NSURL as a security-scoped bookmark to NSUserDefaults |
// (so that we can resolve it again at re-launch), but first resolve the symbolic link to it |
// |
NSError *resolveError = nil; |
NSString *resolvedPath = |
[[NSFileManager defaultManager] destinationOfSymbolicLinkAtPath:picturesDirectoryURL.path |
error:&resolveError]; |
if (resolvedPath != nil && resolveError == nil) |
{ |
NSURL *bookMarkURL = [NSURL fileURLWithPath:resolvedPath isDirectory:YES]; |
NSData *bookmarkData = [bookMarkURL bookmarkDataWithOptions:NSURLBookmarkCreationWithSecurityScope |
includingResourceValuesForKeys:nil |
relativeToURL:nil |
error:&error]; |
NSAssert(bookmarkData != nil, @"could not create security scoped bookmark"); |
[[NSUserDefaults standardUserDefaults] setObject:bookmarkData forKey:Search_Location_Key]; |
[[NSUserDefaults standardUserDefaults] synchronize]; |
self.searchLocation = bookMarkURL; // remember this for later |
} |
} |
NSAssert(self.searchLocation != nil, @"Could not obtain a default search location"); |
// lastly, point our searchLocation NSPathControl to the search location |
(self.searchLocationPathControl).URL = self.searchLocation; |
(self.window).delegate = self; // we want to be notified when this window is closed |
} |
} |
- (BOOL)windowShouldClose:(id)sender { |
for (SearchQuery *query in self.iSearchQueries) { |
// we are no longer interested in accessing SearchQuery's bookmarked search location, |
// so it's important we balance the start/stop access to security scoped bookmarks here |
// |
[query.searchURL stopAccessingSecurityScopedResource]; |
} |
return YES; |
} |
- (void)dealloc { |
[[NSNotificationCenter defaultCenter] removeObserver:self name:SearchQueryChildrenDidChangeNotification object:nil]; |
[[NSNotificationCenter defaultCenter] removeObserver:self name:SearchItemDidChangeNotification object:nil]; |
[[NSNotificationCenter defaultCenter] removeObserver:self name:NSRuleEditorRowsDidChangeNotification object:nil]; |
} |
#pragma mark - NSPredicateEditor support |
// user started a search from the predicate editor |
// |
- (void)createNewSearchForPredicate:(NSPredicate *)predicate withTitle:(NSString *)title withScopeURL:(NSURL *)url { |
if (predicate != nil) { |
// Always search for images |
NSPredicate *imagesPredicate = [NSPredicate predicateWithFormat:@"(kMDItemContentTypeTree = 'public.image')"]; |
predicate = [NSCompoundPredicate andPredicateWithSubpredicates:@[imagesPredicate, predicate]]; |
// we are interested in accessing this bookmark for our SearchQuery class |
[url startAccessingSecurityScopedResource]; |
// Create an instance of our datamodel and keep track of things. |
SearchQuery *searchQuery = [[SearchQuery alloc] initWithSearchPredicate:predicate title:title scopeURL:url]; |
[self.iSearchQueries addObject:searchQuery]; |
// Reload the children of the root item, "nil" |
[self.resultsOutlineView reloadItem:nil reloadChildren:YES]; |
[self.resultsOutlineView expandItem:searchQuery]; |
NSInteger row = [self.resultsOutlineView rowForItem:searchQuery]; |
[self.resultsOutlineView scrollRowToVisible:row]; |
[self.resultsOutlineView selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO]; |
} |
} |
// Foundation's Spotlight support in NSMetdataQuery places the following requirements on an NSPredicate: |
// - Value-type (always YES or NO) predicates are not allowed |
// - Any compound predicate (other than NOT) must have at least two subpredicates |
// The following method will "clean up" an NSPredicate to make it ready for Spotlight, |
// or return nil if the predicate can't be cleaned. |
// |
- (NSPredicate *)spotlightFriendlyPredicate:(id)predicate { |
if ([predicate isEqual:[NSPredicate predicateWithValue:YES]] || [predicate isEqual:[NSPredicate predicateWithValue:NO]]) |
return nil; |
else if ([predicate isKindOfClass:[NSCompoundPredicate class]]) { |
NSCompoundPredicateType type = [predicate compoundPredicateType]; |
NSMutableArray *cleanSubpredicates = [NSMutableArray array]; |
for (NSPredicate *dirtySubpredicate in [predicate subpredicates]) { |
NSPredicate *cleanSubpredicate = [self spotlightFriendlyPredicate:dirtySubpredicate]; |
if (cleanSubpredicate) [cleanSubpredicates addObject:cleanSubpredicate]; |
} |
if (cleanSubpredicates.count == 0) { |
return nil; |
} |
else if (cleanSubpredicates.count == 1 && type != NSNotPredicateType) { |
return cleanSubpredicates[0]; |
} |
else { |
return [[NSCompoundPredicate alloc] initWithType:type subpredicates:cleanSubpredicates]; |
} |
} |
else return predicate; |
} |
// this method gets called whenever the predicate editor changes, |
// but we only want to create a new predicate when the user hits return. |
// So check NSApp currentEvent. |
// |
- (IBAction)predicateEditorChanged:(id)sender { |
NSEvent *event = NSApp.currentEvent; |
if (event.type == NSKeyDown) { |
NSString *characters = event.characters; |
if (characters.length > 0 && [characters characterAtIndex:0] == 0x0D) { |
/* Get the predicate, which is the object value of our view. */ |
NSPredicate *predicate = self.predicateEditor.objectValue; |
/* Make it Spotlight friendly. */ |
predicate = [self spotlightFriendlyPredicate:predicate]; |
if (predicate) { |
static NSInteger searchIndex = 0; |
NSString *title = NSLocalizedString(@"Search %ld", @"Search group title"); |
[self createNewSearchForPredicate:predicate withTitle:[NSString stringWithFormat:title, (long)++searchIndex] withScopeURL:self.searchLocation]; |
} |
} |
} |
} |
- (void)queryChildrenChanged:(NSNotification *)note { |
[self.resultsOutlineView reloadItem:note.object reloadChildren:YES]; |
} |
// called as a result of "SearchItemDidChangeNotification" |
// the item has an updated image preview to display |
// |
- (void)searchItemChanged:(NSNotification *)note { |
// When an item changes, it only will affect the display state. |
// So, we only need to redisplay its contents, and not reload it |
NSInteger row = [self.resultsOutlineView rowForItem:note.object]; |
if (row != -1) { |
// force update the row that needs a re-draw |
[self.resultsOutlineView reloadDataForRowIndexes:[NSIndexSet indexSetWithIndex:row] |
columnIndexes:[NSIndexSet indexSetWithIndex:0]]; |
if ([self.resultsOutlineView isRowSelected:row]) { |
[self updatePathControl]; |
} |
} |
} |
- (void)rowsDidChange:(NSNotification *)notification { |
// adjust the height constraint to our predicate editor's scroll view to accomodate any added or removed rows |
// we use the predicate editor's intrinsic size to help us. |
// |
NSSize intrinsicSize = self.predicateEditor.intrinsicContentSize; |
[self.self.heightConstraint animator].constant = intrinsicSize.height; |
} |
#pragma mark - NSOutlineViewDataSource |
- (NSInteger)outlineView:(NSOutlineView *)outlineView numberOfChildrenOfItem:(id)item { |
NSArray *children = item == nil ? self.iSearchQueries : [item children]; |
return children.count; |
} |
- (id)outlineView:(NSOutlineView *)outlineView child:(NSInteger)index ofItem:(id)item { |
NSArray *children = item == nil ? self.iSearchQueries : [item children]; |
return children[index]; |
} |
- (BOOL)outlineView:(NSOutlineView *)outlineView isItemExpandable:(id)item { |
if ([item isKindOfClass:[SearchQuery class]]) { |
return YES; |
} |
return NO; |
} |
// construct the table cell view for each row in our outline view |
// |
- (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(id)item { |
NSTableCellView *result = nil; |
if ([self outlineView:outlineView isGroupItem:item] && [item isKindOfClass:[SearchQuery class]]) { |
// it's a grouped-style row, find it's view and set the title as the query title |
result = [outlineView makeViewWithIdentifier:VIEW_SEARCH_GROUP owner:self]; |
SearchQuery *query = item; |
result.textField.stringValue = query.title; |
} else if (tableColumn != nil && item != nil) { |
result = [outlineView makeViewWithIdentifier:tableColumn.identifier owner:self]; |
if (result != nil) { |
if (tableColumn == nil || [item isKindOfClass:[SearchQuery class]]) { |
SearchQuery *query = item; |
if ([tableColumn.identifier isEqualToString:COL_IMAGE_ID]) { |
result.textField.stringValue = query.title; |
} |
} else if ([item isKindOfClass:[SearchItem class]]) { |
SearchItem *searchItem = (SearchItem *)item; |
if ([tableColumn.identifier isEqualToString:COL_IMAGE_ID]) { |
if (searchItem.title != nil) { |
result.textField.stringValue = searchItem.title; |
result.imageView.image = searchItem.thumbnailImage; |
NSTextField *subTitle = [result viewWithTag:SubTitle_Tag]; |
NSSize imageSize = [item imageSize]; |
if (imageSize.width > 0) { |
subTitle.stringValue = [NSString stringWithFormat:@"(%ldx%ld)", (long)imageSize.width, (long)imageSize.height]; |
} else { |
subTitle.stringValue = @"--"; |
} |
} |
else { |
result.textField.stringValue = NSLocalizedString(@"(Untitled)", @"Untitled title"); |
} |
// add a tracking area to the info button so we can alter it's image |
NSButton *infoButton = [result viewWithTag:InfoButton_Tag]; |
NSDictionary *userInfo = @{Info_Button_Key: infoButton}; |
NSTrackingArea *trackingArea = [[NSTrackingArea alloc] initWithRect:infoButton.frame |
options:(NSTrackingEnabledDuringMouseDrag | NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways) |
owner:self |
userInfo:userInfo]; |
[result addTrackingArea:trackingArea]; |
infoButton.target = self; |
infoButton.action = @selector(infoButtonAction:); |
} else if ([tableColumn.identifier isEqualToString:COL_CAMERA_MODEL_ID]) { |
if (searchItem.cameraModel != nil) |
{ |
result.textField.stringValue = searchItem.cameraModel; |
} |
else { |
result.textField.stringValue = NSLocalizedString(@"(Unknown)", @"Unknown camera model name"); |
} |
} else if ([tableColumn.identifier isEqualToString:COL_LAST_MODIFIED_ID]) { |
result.textField.stringValue = [self.dateFormatter stringFromDate:searchItem.modifiedDate]; |
} |
} |
} |
} |
return result; |
} |
#pragma mark - NSOutlineViewDelegate |
- (CGFloat)outlineView:(NSOutlineView *)outlineView heightOfRowByItem:(id)item { |
CGFloat rowHeight = outlineView.rowHeight; |
if ([item isKindOfClass:[SearchItem class]]) { |
SearchItem *searchItem = item; |
if (searchItem.metadataItem != nil) { |
// We could dynamically change the thumbnail size, if desired |
rowHeight = self.iThumbnailSize + 9.0; // The extra space is padding around the cell |
} |
} |
return rowHeight; |
} |
- (BOOL)outlineView:(NSOutlineView *)outlineView isGroupItem:(id)item { |
return [item isKindOfClass:[SearchQuery class]]; |
} |
// used upon selection so we can set the info button's icon as selected |
- (void)outlineViewSelectionDidChange:(NSNotification *)notification { |
[self updatePathControl]; // change the path control to reflect the current selection in the outline view |
// change the all the info button images to reflect the current selection in the outline view |
NSIndexSet *selectedRows = self.resultsOutlineView.selectedRowIndexes; |
if (selectedRows.count > 0) { |
for (NSUInteger rowIndex = selectedRows.firstIndex; rowIndex <= selectedRows.lastIndex; rowIndex = [selectedRows indexGreaterThanIndex:rowIndex]) { |
NSTableRowView *rowView = [self.resultsOutlineView rowViewAtRow:rowIndex makeIfNecessary:NO]; |
if (rowView != nil) { |
NSButton *infoButton = [rowView viewWithTag:InfoButton_Tag]; |
if (infoButton != nil) { |
infoButton.image = [NSImage imageNamed:@"info-selected"]; |
} |
} |
} |
} |
} |
// used upon de-selection so we can restore the info button's icon |
- (NSIndexSet *)outlineView:(NSOutlineView *)outlineView selectionIndexesForProposedSelection:(NSIndexSet *)proposedSelectionIndexes { |
NSIndexSet *selectedRows = self.resultsOutlineView.selectedRowIndexes; |
if (selectedRows.count > 0) { |
for (NSUInteger rowIndex = selectedRows.firstIndex; rowIndex <= selectedRows.lastIndex; rowIndex = [selectedRows indexGreaterThanIndex:rowIndex]) { |
NSTableRowView *rowView = [self.resultsOutlineView rowViewAtRow:rowIndex makeIfNecessary:NO]; |
if (rowView != nil) { |
NSButton *infoButton = [rowView viewWithTag:InfoButton_Tag]; |
if (infoButton != nil) { |
infoButton.image = [NSImage imageNamed:@"info-normal"]; |
} |
} |
} |
} |
return proposedSelectionIndexes; |
} |
#pragma mark - Action Methods |
- (void)updatePathControl { |
// Clear out the prior cells |
[self.pathControl setPathComponentCells:@[]]; |
// Watch for the selection to change in order to update the path control |
NSIndexSet *selection = self.resultsOutlineView.selectedRowIndexes; |
if (selection.count == 0) { |
NSString *str = NSLocalizedString(@"Select an item to show its location.", @"Text to display in path control to select a location"); |
((NSFormCell *)self.pathControl.cell).placeholderString = str; |
} else if (selection.count == 1) { |
id selectedItem = [self.resultsOutlineView itemAtRow:selection.firstIndex]; |
if ([selectedItem isKindOfClass:[SearchItem class]]) { |
SearchItem *searchItem = selectedItem; |
self.pathControl.URL = searchItem.filePathURL; |
} else { |
NSDictionary *attrs = @{NSForegroundColorAttributeName: [[NSColor blueColor] colorWithAlphaComponent:0.5], |
NSFontAttributeName: [NSFont systemFontOfSize:[NSFont systemFontSize]]}; |
NSAttributedString *str = [[NSAttributedString alloc] initWithString:[selectedItem title] attributes:attrs]; |
((NSFormCell *)self.pathControl.cell).placeholderAttributedString = str; |
} |
} else { |
NSString *str = NSLocalizedString(@"Multiple items selected.", @"Text to display in path control when multiple items are selected"); |
((NSFormCell *)self.pathControl.cell).placeholderString = str; |
} |
} |
- (void)resultsOutlineDoubleClickAction:(NSOutlineView *)sender { |
// Open a page for all the selected items |
NSIndexSet *selectedRows = sender.selectedRowIndexes; |
if (selectedRows.count > 0) { |
for (NSUInteger i = selectedRows.firstIndex; i <= selectedRows.lastIndex; i = [selectedRows indexGreaterThanIndex:i]) { |
id item = [sender itemAtRow:i]; |
if ([item isKindOfClass:[SearchItem class]]) { |
[[NSWorkspace sharedWorkspace] openURL:[item filePathURL]]; |
} |
} |
} |
} |
- (void)infoButtonAction:(NSOutlineView *)sender { |
// Access the row that was clicked on and open that image |
NSInteger row = [self.resultsOutlineView rowForView:sender]; |
if (row > 0) { |
id item = [self.resultsOutlineView itemAtRow:row]; |
if ([item isKindOfClass:[SearchItem class]]) { |
if ([item filePathURL]) { |
NSPasteboard *pboard = [NSPasteboard pasteboardWithUniqueName]; |
[pboard declareTypes:@[NSStringPboardType] owner:nil]; |
[pboard setString:[item filePathURL].path forType:NSStringPboardType]; |
NSPerformService(@"Finder/Show Info", pboard); |
} |
} |
} |
} |
#pragma mark - NSPathControl support |
- (void)pathControlDoubleClick:(id)sender { |
if ([self.pathControl clickedPathComponentCell] != nil) { |
[[NSWorkspace sharedWorkspace] openURL:[self.pathControl clickedPathComponentCell].URL]; |
} |
} |
- (IBAction)searchLocationChanged:(id)sender { |
self.searchLocation = [sender URL]; |
// write out the NSURL as a security-scoped bookmark to NSUserDefaults |
// (so that we can resolve it again at re-launch) |
// |
NSData *bookmarkData = [self.searchLocation bookmarkDataWithOptions:NSURLBookmarkCreationWithSecurityScope |
includingResourceValuesForKeys:nil |
relativeToURL:nil |
error:nil]; |
[[NSUserDefaults standardUserDefaults] setObject:bookmarkData forKey:Search_Location_Key]; |
[[NSUserDefaults standardUserDefaults] synchronize]; |
} |
// delegate method to NSPathControl to determine how the NSOpenPanel will look/behavior |
- (void)pathControl:(NSPathControl *)pathControl willDisplayOpenPanel:(NSOpenPanel *)openPanel { |
// customize the open panel to choose directories |
openPanel.allowsMultipleSelection = NO; |
openPanel.message = @"Choose a location to search for photos and images:"; |
openPanel.canChooseDirectories = YES; |
openPanel.canChooseFiles = NO; |
openPanel.prompt = @"Choose"; |
openPanel.title = @"Choose Location"; |
// set the default location to the Documents folder |
NSArray *documentsFolderPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES); |
openPanel.directoryURL = [NSURL fileURLWithPath:documentsFolderPath[0]]; |
} |
#pragma mark - Mouse tracking support |
- (void)mouseEntered:(NSEvent *)theEvent { |
// alter the button image to the hovered representation |
NSDictionary *userInfo = theEvent.userData; |
NSButton *button = [userInfo valueForKey:Info_Button_Key]; |
// decide which image to use for entering the tracking area |
NSPoint viewLocation = [self.resultsOutlineView convertPoint:theEvent.locationInWindow fromView:nil]; |
NSInteger rowAtPoint = [self.resultsOutlineView rowAtPoint:viewLocation]; |
if ([self.resultsOutlineView isRowSelected:rowAtPoint]) { |
button.image = [NSImage imageNamed:@"info-selected-hovered"]; |
} else { |
button.image = [NSImage imageNamed:@"info-normal-hovered"]; |
} |
} |
- (void)mouseExited:(NSEvent *)theEvent { |
// reset the button image to normal representation |
NSDictionary *userInfo = theEvent.userData; |
NSButton *button = [userInfo valueForKey:Info_Button_Key]; |
// decide which image to use for entering the tracking area |
NSPoint viewLocation = [self.resultsOutlineView convertPoint:theEvent.locationInWindow fromView:nil]; |
NSInteger rowAtPoint = [self.resultsOutlineView rowAtPoint:viewLocation]; |
if ([self.resultsOutlineView isRowSelected:rowAtPoint]) { |
button.image = [NSImage imageNamed:@"info-selected"]; |
} else { |
button.image = [NSImage imageNamed:@"info-normal"]; |
} |
} |
@end |
Copyright © 2015 Apple Inc. All Rights Reserved. Terms of Use | Privacy Policy | Updated: 2015-12-03