| Index: chrome/browser/cocoa/keystone_glue.mm
|
| ===================================================================
|
| --- chrome/browser/cocoa/keystone_glue.mm (revision 32993)
|
| +++ chrome/browser/cocoa/keystone_glue.mm (working copy)
|
| @@ -2,19 +2,41 @@
|
| // Use of this source code is governed by a BSD-style license that can be
|
| // found in the LICENSE file.
|
|
|
| -#import "chrome/app/keystone_glue.h"
|
| +#import "chrome/browser/cocoa/keystone_glue.h"
|
|
|
| +#include <sys/param.h>
|
| +#include <sys/mount.h>
|
| +
|
| +#include <vector>
|
| +
|
| +#import "app/l10n_util_mac.h"
|
| #include "base/logging.h"
|
| #include "base/mac_util.h"
|
| #import "base/worker_pool_mac.h"
|
| +#include "chrome/browser/cocoa/authorization_util.h"
|
| #include "chrome/common/chrome_constants.h"
|
| +#include "grit/chromium_strings.h"
|
| +#include "grit/generated_resources.h"
|
|
|
| namespace {
|
|
|
| // Provide declarations of the Keystone registration bits needed here. From
|
| // KSRegistration.h.
|
| -typedef enum { kKSPathExistenceChecker } KSExistenceCheckerType;
|
| +typedef enum {
|
| + kKSPathExistenceChecker,
|
| +} KSExistenceCheckerType;
|
|
|
| +typedef enum {
|
| + kKSRegistrationUserTicket,
|
| + kKSRegistrationSystemTicket,
|
| + kKSRegistrationDontKnowWhatKindOfTicket,
|
| +} KSRegistrationTicketType;
|
| +
|
| +NSString *KSRegistrationDidCompleteNotification =
|
| + @"KSRegistrationDidCompleteNotification";
|
| +NSString *KSRegistrationPromotionDidCompleteNotification =
|
| + @"KSRegistrationPromotionDidCompleteNotification";
|
| +
|
| NSString *KSRegistrationCheckForUpdateNotification =
|
| @"KSRegistrationCheckForUpdateNotification";
|
| NSString *KSRegistrationStatusKey = @"Status";
|
| @@ -42,14 +64,26 @@
|
| preserveTTToken:(BOOL)preserveToken
|
| tag:(NSString*)tag;
|
|
|
| +- (BOOL)promoteWithVersion:(NSString*)version
|
| + existenceCheckerType:(KSExistenceCheckerType)xctype
|
| + existenceCheckerString:(NSString*)xc
|
| + serverURLString:(NSString*)serverURLString
|
| + preserveTTToken:(BOOL)preserveToken
|
| + tag:(NSString*)tag
|
| + authorization:(AuthorizationRef)authorization;
|
| +
|
| - (void)setActive;
|
| - (void)checkForUpdate;
|
| - (void)startUpdate;
|
| +- (KSRegistrationTicketType)ticketType;
|
|
|
| @end // @interface KSRegistration
|
|
|
| @interface KeystoneGlue(Private)
|
|
|
| +// Called when Keystone registration completes.
|
| +- (void)registrationComplete:(NSNotification*)notification;
|
| +
|
| // Called periodically to announce activity by pinging the Keystone server.
|
| - (void)markActive:(NSTimer*)timer;
|
|
|
| @@ -73,18 +107,37 @@
|
| //
|
| // -determineUpdateStatusAsync is called on the main thread to initiate the
|
| // operation. It performs initial set-up work that must be done on the main
|
| -// thread and arranges for -determineUpdateStatusAtPath: to be called on a
|
| -// work queue thread managed by NSOperationQueue.
|
| -// -determineUpdateStatusAtPath: then reads the Info.plist, gets the version
|
| -// from the CFBundleShortVersionString key, and performs
|
| +// thread and arranges for -determineUpdateStatus to be called on a work queue
|
| +// thread managed by NSOperationQueue.
|
| +// -determineUpdateStatus then reads the Info.plist, gets the version from the
|
| +// CFBundleShortVersionString key, and performs
|
| // -determineUpdateStatusForVersion: on the main thread.
|
| // -determineUpdateStatusForVersion: does the actual comparison of the version
|
| // on disk with the running version and calls -updateStatus:version: with the
|
| // results of its analysis.
|
| - (void)determineUpdateStatusAsync;
|
| -- (void)determineUpdateStatusAtPath:(NSString*)appPath;
|
| +- (void)determineUpdateStatus;
|
| - (void)determineUpdateStatusForVersion:(NSString*)version;
|
|
|
| +// Returns YES if registration_ is definitely on a user ticket. If definitely
|
| +// on a system ticket, or uncertain of ticket type (due to an older version
|
| +// of Keystone being used), returns NO.
|
| +- (BOOL)isUserTicket;
|
| +
|
| +// Called when ticket promotion completes.
|
| +- (void)promotionComplete:(NSNotification*)notification;
|
| +
|
| +// Changes the application's ownership and permissions so that all files are
|
| +// owned by root:wheel and all files and directories are writable only by
|
| +// root, but readable and executable as needed by everyone.
|
| +// -changePermissionsForPromotionAsync is called on the main thread by
|
| +// -promotionComplete. That routine calls
|
| +// -changePermissionsForPromotionWithTool: on a work queue thread. When done,
|
| +// -changePermissionsForPromotionComplete is called on the main thread.
|
| +- (void)changePermissionsForPromotionAsync;
|
| +- (void)changePermissionsForPromotionWithTool:(NSString*)toolPath;
|
| +- (void)changePermissionsForPromotionComplete;
|
| +
|
| @end // @interface KeystoneGlue(Private)
|
|
|
| const NSString* const kAutoupdateStatusNotification =
|
| @@ -94,11 +147,14 @@
|
|
|
| @implementation KeystoneGlue
|
|
|
| -// TODO(jrg): use base::SingletonObjC<KeystoneGlue>
|
| -static KeystoneGlue* sDefaultKeystoneGlue = nil; // leaked
|
| ++ (id)defaultKeystoneGlue {
|
| + static bool sTriedCreatingDefaultKeystoneGlue = false;
|
| + // TODO(jrg): use base::SingletonObjC<KeystoneGlue>
|
| + static KeystoneGlue* sDefaultKeystoneGlue = nil; // leaked
|
|
|
| -+ (id)defaultKeystoneGlue {
|
| - if (!sDefaultKeystoneGlue) {
|
| + if (!sTriedCreatingDefaultKeystoneGlue) {
|
| + sTriedCreatingDefaultKeystoneGlue = true;
|
| +
|
| sDefaultKeystoneGlue = [[KeystoneGlue alloc] init];
|
| [sDefaultKeystoneGlue loadParameters];
|
| if (![sDefaultKeystoneGlue loadKeystoneRegistration]) {
|
| @@ -114,6 +170,16 @@
|
| NSNotificationCenter* center = [NSNotificationCenter defaultCenter];
|
|
|
| [center addObserver:self
|
| + selector:@selector(registrationComplete:)
|
| + name:KSRegistrationDidCompleteNotification
|
| + object:nil];
|
| +
|
| + [center addObserver:self
|
| + selector:@selector(promotionComplete:)
|
| + name:KSRegistrationPromotionDidCompleteNotification
|
| + object:nil];
|
| +
|
| + [center addObserver:self
|
| selector:@selector(checkForUpdateComplete:)
|
| name:KSRegistrationCheckForUpdateNotification
|
| object:nil];
|
| @@ -128,8 +194,9 @@
|
| }
|
|
|
| - (void)dealloc {
|
| + [productID_ release];
|
| + [appPath_ release];
|
| [url_ release];
|
| - [productID_ release];
|
| [version_ release];
|
| [channel_ release];
|
| [registration_ release];
|
| @@ -144,15 +211,24 @@
|
|
|
| - (void)loadParameters {
|
| NSDictionary* infoDictionary = [self infoDictionary];
|
| +
|
| + // Use [NSBundle mainBundle] to get the application's own bundle identifier
|
| + // and path, not the framework's. For auto-update, the application is
|
| + // what's significant here: it's used to locate the outermost part of the
|
| + // application for the existence checker and other operations that need to
|
| + // see the entire application bundle.
|
| + NSBundle* appBundle = [NSBundle mainBundle];
|
| +
|
| + NSString* productID = [infoDictionary objectForKey:@"KSProductID"];
|
| + if (productID == nil) {
|
| + productID = [appBundle bundleIdentifier];
|
| + }
|
| +
|
| + NSString* appPath = [appBundle bundlePath];
|
| NSString* url = [infoDictionary objectForKey:@"KSUpdateURL"];
|
| - NSString* product = [infoDictionary objectForKey:@"KSProductID"];
|
| - if (product == nil) {
|
| - // Use [NSBundle mainBundle] to fall back to the app's own bundle
|
| - // identifier, not the app framework's.
|
| - product = [[NSBundle mainBundle] bundleIdentifier];
|
| - }
|
| NSString* version = [infoDictionary objectForKey:@"KSVersion"];
|
| - if (!product || !url || !version) {
|
| +
|
| + if (!productID || !appPath || !url || !version) {
|
| // If parameters required for Keystone are missing, don't use it.
|
| return;
|
| }
|
| @@ -163,14 +239,15 @@
|
| if (channel == nil)
|
| channel = KSRegistrationRemoveExistingTag;
|
|
|
| + productID_ = [productID retain];
|
| + appPath_ = [appPath retain];
|
| url_ = [url retain];
|
| - productID_ = [product retain];
|
| version_ = [version retain];
|
| channel_ = [channel retain];
|
| }
|
|
|
| - (BOOL)loadKeystoneRegistration {
|
| - if (!productID_ || !url_ || !version_)
|
| + if (!productID_ || !appPath_ || !url_ || !version_)
|
| return NO;
|
|
|
| // Load the KeystoneRegistration framework bundle if present. It lives
|
| @@ -192,16 +269,21 @@
|
| }
|
|
|
| - (void)registerWithKeystone {
|
| - // The existence checks should use the path to the app bundle, not the
|
| - // app framework bundle, so use [NSBundle mainBundle] instead of
|
| - // mac_util::MainBundle().
|
| - [registration_ registerWithVersion:version_
|
| - existenceCheckerType:kKSPathExistenceChecker
|
| - existenceCheckerString:[[NSBundle mainBundle] bundlePath]
|
| - serverURLString:url_
|
| - preserveTTToken:YES
|
| - tag:channel_];
|
| + [self updateStatus:kAutoupdateRegistering version:nil];
|
|
|
| + if (![registration_ registerWithVersion:version_
|
| + existenceCheckerType:kKSPathExistenceChecker
|
| + existenceCheckerString:appPath_
|
| + serverURLString:url_
|
| + preserveTTToken:YES
|
| + tag:channel_]) {
|
| + [self updateStatus:kAutoupdateRegisterFailed version:nil];
|
| + return;
|
| + }
|
| +
|
| + // Upon completion, KSRegistrationDidCompleteNotification will be posted,
|
| + // and -registrationComplete: will be called.
|
| +
|
| // Mark an active RIGHT NOW; don't wait an hour for the first one.
|
| [registration_ setActive];
|
|
|
| @@ -213,6 +295,16 @@
|
| repeats:YES];
|
| }
|
|
|
| +- (void)registrationComplete:(NSNotification*)notification {
|
| + NSDictionary* userInfo = [notification userInfo];
|
| + if ([[userInfo objectForKey:KSRegistrationStatusKey] boolValue]) {
|
| + [self updateStatus:kAutoupdateRegistered version:nil];
|
| + } else {
|
| + // Dump registration_?
|
| + [self updateStatus:kAutoupdateRegisterFailed version:nil];
|
| + }
|
| +}
|
| +
|
| - (void)stopTimer {
|
| [timer_ invalidate];
|
| }
|
| @@ -290,28 +382,24 @@
|
|
|
| // Runs on the main thread.
|
| - (void)determineUpdateStatusAsync {
|
| - // NSBundle is not documented as being thread-safe. Do NSBundle operations
|
| - // on the main thread before jumping over to a NSOperationQueue-managed
|
| - // thread to do blocking file input.
|
| DCHECK([NSThread isMainThread]);
|
|
|
| - SEL selector = @selector(determineUpdateStatusAtPath:);
|
| - NSString* appPath = [[NSBundle mainBundle] bundlePath];
|
| + SEL selector = @selector(determineUpdateStatus);
|
| NSInvocationOperation* operation =
|
| [[[NSInvocationOperation alloc] initWithTarget:self
|
| selector:selector
|
| - object:appPath] autorelease];
|
| + object:nil] autorelease];
|
|
|
| NSOperationQueue* operationQueue = [WorkerPoolObjC sharedOperationQueue];
|
| [operationQueue addOperation:operation];
|
| }
|
|
|
| // Runs on a thread managed by NSOperationQueue.
|
| -- (void)determineUpdateStatusAtPath:(NSString*)appPath {
|
| +- (void)determineUpdateStatus {
|
| DCHECK(![NSThread isMainThread]);
|
|
|
| NSString* appInfoPlistPath =
|
| - [[appPath stringByAppendingPathComponent:@"Contents"]
|
| + [[appPath_ stringByAppendingPathComponent:@"Contents"]
|
| stringByAppendingPathComponent:@"Info.plist"];
|
| NSDictionary* infoPlist =
|
| [NSDictionary dictionaryWithContentsOfFile:appInfoPlistPath];
|
| @@ -383,7 +471,258 @@
|
|
|
| - (BOOL)asyncOperationPending {
|
| AutoupdateStatus status = [self recentStatus];
|
| - return status == kAutoupdateChecking || status == kAutoupdateInstalling;
|
| + return status == kAutoupdateRegistering ||
|
| + status == kAutoupdateChecking ||
|
| + status == kAutoupdateInstalling ||
|
| + status == kAutoupdatePromoting;
|
| }
|
|
|
| +- (BOOL)isUserTicket {
|
| + return [registration_ ticketType] == kKSRegistrationUserTicket;
|
| +}
|
| +
|
| +- (BOOL)isOnReadOnlyFilesystem {
|
| + const char* appPathC = [appPath_ fileSystemRepresentation];
|
| + struct statfs statfsBuf;
|
| +
|
| + if (statfs(appPathC, &statfsBuf) != 0) {
|
| + PLOG(ERROR) << "statfs";
|
| + // Be optimistic about the filesystem's writability.
|
| + return NO;
|
| + }
|
| +
|
| + return (statfsBuf.f_flags & MNT_RDONLY) != 0;
|
| +}
|
| +
|
| +- (BOOL)needsPromotion {
|
| + if (![self isUserTicket] || [self isOnReadOnlyFilesystem]) {
|
| + return NO;
|
| + }
|
| +
|
| + // Check the outermost bundle directory, the main executable path, and the
|
| + // framework directory. It may be enough to just look at the outermost
|
| + // bundle directory, but checking an interior file and directory can be
|
| + // helpful in case permissions are set differently only on the outermost
|
| + // directory. An interior file and directory are both checked because some
|
| + // file operations, such as Snow Leopard's Finder's copy operation when
|
| + // authenticating, may actually result in different ownership being applied
|
| + // to files and directories.
|
| + NSFileManager* fileManager = [NSFileManager defaultManager];
|
| + NSString* executablePath = [[NSBundle mainBundle] executablePath];
|
| + NSString* frameworkPath = [mac_util::MainAppBundle() bundlePath];
|
| + return ![fileManager isWritableFileAtPath:appPath_] ||
|
| + ![fileManager isWritableFileAtPath:executablePath] ||
|
| + ![fileManager isWritableFileAtPath:frameworkPath];
|
| +}
|
| +
|
| +- (BOOL)wantsPromotion {
|
| + // -needsPromotion checks these too, but this method doesn't necessarily
|
| + // return NO just becuase -needsPromotion returns NO, so another check is
|
| + // needed here.
|
| + if (![self isUserTicket] || [self isOnReadOnlyFilesystem]) {
|
| + return NO;
|
| + }
|
| +
|
| + if ([self needsPromotion]) {
|
| + return YES;
|
| + }
|
| +
|
| + return [appPath_ hasPrefix:@"/Applications/"];
|
| +}
|
| +
|
| +- (void)promoteTicket {
|
| + if ([self asyncOperationPending] || ![self wantsPromotion]) {
|
| + // Because there are multiple ways of reaching promoteTicket that might
|
| + // not lock each other out, it may be possible to arrive here while an
|
| + // asynchronous operation is pending, or even after promotion has already
|
| + // occurred. Just quietly return without doing anything.
|
| + return;
|
| + }
|
| +
|
| + // Create an empty AuthorizationRef.
|
| + scoped_AuthorizationRef authorization;
|
| + OSStatus status = AuthorizationCreate(NULL,
|
| + kAuthorizationEmptyEnvironment,
|
| + kAuthorizationFlagDefaults,
|
| + &authorization);
|
| + if (status != errAuthorizationSuccess) {
|
| + LOG(ERROR) << "AuthorizationCreate: " << status;
|
| + return;
|
| + }
|
| +
|
| + // Specify the "system.privilege.admin" right, which allows
|
| + // AuthorizationExecuteWithPrivileges to run commands as root.
|
| + AuthorizationItem rightItems[] = {
|
| + {kAuthorizationRightExecute, 0, NULL, 0}
|
| + };
|
| + AuthorizationRights rights = {arraysize(rightItems), rightItems};
|
| +
|
| + // product_logo_32.png is used instead of app.icns because Authorization
|
| + // Services requires an image that NSImage can read.
|
| + NSString* iconPath =
|
| + [mac_util::MainAppBundle() pathForResource:@"product_logo_32"
|
| + ofType:@"png"];
|
| + const char* iconPathC = [iconPath fileSystemRepresentation];
|
| + size_t iconPathLength = iconPathC ? strlen(iconPathC) : 0;
|
| +
|
| + // The OS will append " Type an administrator's name and password to allow
|
| + // <CFBundleDisplayName> to make changes."
|
| + NSString* prompt = l10n_util::GetNSStringFWithFixup(
|
| + IDS_PROMOTE_AUTHENTICATION_PROMPT,
|
| + l10n_util::GetStringUTF16(IDS_PRODUCT_NAME));
|
| + const char* promptC = [prompt UTF8String];
|
| + size_t promptLength = promptC ? strlen(promptC) : 0;
|
| +
|
| + AuthorizationItem environmentItems[] = {
|
| + {kAuthorizationEnvironmentIcon, iconPathLength, (void*)iconPathC, 0},
|
| + {kAuthorizationEnvironmentPrompt, promptLength, (void*)promptC, 0}
|
| + };
|
| +
|
| + AuthorizationEnvironment environment = {arraysize(environmentItems),
|
| + environmentItems};
|
| +
|
| + AuthorizationFlags flags = kAuthorizationFlagDefaults |
|
| + kAuthorizationFlagInteractionAllowed |
|
| + kAuthorizationFlagExtendRights |
|
| + kAuthorizationFlagPreAuthorize;
|
| +
|
| + status = AuthorizationCopyRights(authorization,
|
| + &rights,
|
| + &environment,
|
| + flags,
|
| + NULL);
|
| + if (status != errAuthorizationSuccess) {
|
| + if (status != errAuthorizationCanceled) {
|
| + LOG(ERROR) << "AuthorizationCopyRights: " << status;
|
| + }
|
| + return;
|
| + }
|
| +
|
| + [self updateStatus:kAutoupdatePromoting version:nil];
|
| +
|
| + // TODO(mark): Remove when able!
|
| + //
|
| + // keystone_promote_preflight is hopefully temporary. It's here to ensure
|
| + // that the Keystone system ticket store is in a usable state for all users
|
| + // on the system. Ideally, Keystone's installer or another part of Keystone
|
| + // would handle this. The underlying problem is http://b/2285921, and it
|
| + // causes http://b/2289908, which this workaround addresses.
|
| + //
|
| + // This is run synchronously, which isn't optimal, but
|
| + // -[KSRegistration promoteWithVersion:...] is currently synchronous too,
|
| + // and this operation needs to happen before that one.
|
| + //
|
| + // TODO(mark): Make asynchronous. That only makes sense if the promotion
|
| + // operation itself is asynchronous too. http://b/2290009. Hopefully,
|
| + // the Keystone promotion code will just be changed to do what preflight
|
| + // now does, and then the preflight script can be removed instead.
|
| + NSString* preflightPath =
|
| + [mac_util::MainAppBundle() pathForResource:@"keystone_promote_preflight"
|
| + ofType:@"sh"];
|
| + const char* preflightPathC = [preflightPath fileSystemRepresentation];
|
| + const char* arguments[] = {NULL};
|
| +
|
| + int exit_status;
|
| + status = authorization_util::ExecuteWithPrivilegesAndWait(
|
| + authorization,
|
| + preflightPathC,
|
| + kAuthorizationFlagDefaults,
|
| + arguments,
|
| + NULL, // pipe
|
| + &exit_status);
|
| + if (status != errAuthorizationSuccess) {
|
| + LOG(ERROR) << "AuthorizationExecuteWithPrivileges preflight: " << status;
|
| + [self updateStatus:kAutoupdatePromoteFailed version:nil];
|
| + return;
|
| + }
|
| + if (exit_status != 0) {
|
| + LOG(ERROR) << "keystone_promote_preflight status " << exit_status;
|
| + [self updateStatus:kAutoupdatePromoteFailed version:nil];
|
| + return;
|
| + }
|
| +
|
| + // Hang on to the AuthorizationRef so that it can be used once promotion is
|
| + // complete. Do this before asking Keystone to promote the ticket, because
|
| + // -promotionComplete: may be called from inside the Keystone promotion
|
| + // call.
|
| + authorization_.swap(authorization);
|
| +
|
| + if (![registration_ promoteWithVersion:version_
|
| + existenceCheckerType:kKSPathExistenceChecker
|
| + existenceCheckerString:appPath_
|
| + serverURLString:url_
|
| + preserveTTToken:YES
|
| + tag:channel_
|
| + authorization:authorization_]) {
|
| + [self updateStatus:kAutoupdatePromoteFailed version:nil];
|
| + authorization_.reset();
|
| + return;
|
| + }
|
| +
|
| + // Upon completion, KSRegistrationPromotionDidCompleteNotification will be
|
| + // posted, and -promotionComplete: will be called.
|
| +}
|
| +
|
| +- (void)promotionComplete:(NSNotification*)notification {
|
| + NSDictionary* userInfo = [notification userInfo];
|
| + if ([[userInfo objectForKey:KSRegistrationStatusKey] boolValue]) {
|
| + [self changePermissionsForPromotionAsync];
|
| + } else {
|
| + authorization_.reset();
|
| + [self updateStatus:kAutoupdatePromoteFailed version:nil];
|
| + }
|
| +}
|
| +
|
| +- (void)changePermissionsForPromotionAsync {
|
| + // NSBundle is not documented as being thread-safe. Do NSBundle operations
|
| + // on the main thread before jumping over to a NSOperationQueue-managed
|
| + // thread to run the tool.
|
| + DCHECK([NSThread isMainThread]);
|
| +
|
| + SEL selector = @selector(changePermissionsForPromotionWithTool:);
|
| + NSString* toolPath =
|
| + [mac_util::MainAppBundle() pathForResource:@"keystone_promote_postflight"
|
| + ofType:@"sh"];
|
| +
|
| + NSInvocationOperation* operation =
|
| + [[[NSInvocationOperation alloc] initWithTarget:self
|
| + selector:selector
|
| + object:toolPath] autorelease];
|
| +
|
| + NSOperationQueue* operationQueue = [WorkerPoolObjC sharedOperationQueue];
|
| + [operationQueue addOperation:operation];
|
| +}
|
| +
|
| +- (void)changePermissionsForPromotionWithTool:(NSString*)toolPath {
|
| + const char* toolPathC = [toolPath fileSystemRepresentation];
|
| +
|
| + const char* appPathC = [appPath_ fileSystemRepresentation];
|
| + const char* arguments[] = {appPathC, NULL};
|
| +
|
| + int exit_status;
|
| + OSStatus status = authorization_util::ExecuteWithPrivilegesAndWait(
|
| + authorization_,
|
| + toolPathC,
|
| + kAuthorizationFlagDefaults,
|
| + arguments,
|
| + NULL, // pipe
|
| + &exit_status);
|
| + if (status != errAuthorizationSuccess) {
|
| + LOG(ERROR) << "AuthorizationExecuteWithPrivileges postflight: " << status;
|
| + } else if (exit_status != 0) {
|
| + LOG(ERROR) << "keystone_promote_postflight status " << exit_status;
|
| + }
|
| +
|
| + SEL selector = @selector(changePermissionsForPromotionComplete);
|
| + [self performSelectorOnMainThread:selector
|
| + withObject:nil
|
| + waitUntilDone:NO];
|
| +}
|
| +
|
| +- (void)changePermissionsForPromotionComplete {
|
| + authorization_.reset();
|
| +
|
| + [self updateStatus:kAutoupdatePromoted version:nil];
|
| +}
|
| +
|
| @end // @implementation KeystoneGlue
|
|
|