Skip to content

Commit 6848a88

Browse files
authored
Preserve bundle creation date when creating and applying delta updates (#2583)
This will preserve the file creation date of the new app bundle, but not the file creation date of any of the files inside the new app bundle because tracking those changes is complex/undesirable. This bumps the major binary delta version to 4. A new test has been added for testing that the new bundle creation date is also preserved.
1 parent 8de8db0 commit 6848a88

9 files changed

+119
-22
lines changed

Autoupdate/SPUDeltaArchive.m

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,9 @@ @implementation SPUDeltaArchiveHeader
8989
@synthesize fileSystemCompression = _fileSystemCompression;
9090
@synthesize majorVersion = _majorVersion;
9191
@synthesize minorVersion = _minorVersion;
92+
@synthesize bundleCreationDate = _bundleCreationDate;
9293

93-
- (instancetype)initWithCompression:(SPUDeltaCompressionMode)compression compressionLevel:(uint8_t)compressionLevel fileSystemCompression:(bool)fileSystemCompression majorVersion:(uint16_t)majorVersion minorVersion:(uint16_t)minorVersion beforeTreeHash:(const unsigned char *)beforeTreeHash afterTreeHash:(const unsigned char *)afterTreeHash
94+
- (instancetype)initWithCompression:(SPUDeltaCompressionMode)compression compressionLevel:(uint8_t)compressionLevel fileSystemCompression:(bool)fileSystemCompression majorVersion:(uint16_t)majorVersion minorVersion:(uint16_t)minorVersion beforeTreeHash:(const unsigned char *)beforeTreeHash afterTreeHash:(const unsigned char *)afterTreeHash bundleCreationDate:(nullable NSDate *)bundleCreationDate
9495
{
9596
self = [super init];
9697
if (self != nil)
@@ -104,6 +105,8 @@ - (instancetype)initWithCompression:(SPUDeltaCompressionMode)compression compres
104105

105106
memcpy(_beforeTreeHash, beforeTreeHash, sizeof(_beforeTreeHash));
106107
memcpy(_afterTreeHash, afterTreeHash, sizeof(_afterTreeHash));
108+
109+
_bundleCreationDate = bundleCreationDate;
107110
}
108111
return self;
109112
}

Autoupdate/SPUDeltaArchiveProtocol.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ typedef NS_ENUM(uint8_t, SPUDeltaItemCommands) {
2626
// Represents header for our archive
2727
SPU_OBJC_DIRECT_MEMBERS @interface SPUDeltaArchiveHeader : NSObject
2828

29-
- (instancetype)initWithCompression:(SPUDeltaCompressionMode)compression compressionLevel:(uint8_t)compressionLevel fileSystemCompression:(bool)fileSystemCompression majorVersion:(uint16_t)majorVersion minorVersion:(uint16_t)minorVersion beforeTreeHash:(const unsigned char *)beforeTreeHash afterTreeHash:(const unsigned char *)afterTreeHash;
29+
- (instancetype)initWithCompression:(SPUDeltaCompressionMode)compression compressionLevel:(uint8_t)compressionLevel fileSystemCompression:(bool)fileSystemCompression majorVersion:(uint16_t)majorVersion minorVersion:(uint16_t)minorVersion beforeTreeHash:(const unsigned char *)beforeTreeHash afterTreeHash:(const unsigned char *)afterTreeHash bundleCreationDate:(nullable NSDate *)bundleCreationDate;
3030

3131
@property (nonatomic, readonly) SPUDeltaCompressionMode compression;
3232
@property (nonatomic, readonly) uint8_t compressionLevel;
@@ -35,6 +35,7 @@ SPU_OBJC_DIRECT_MEMBERS @interface SPUDeltaArchiveHeader : NSObject
3535
@property (nonatomic, readonly) uint16_t minorVersion;
3636
@property (nonatomic, readonly) unsigned char *beforeTreeHash;
3737
@property (nonatomic, readonly) unsigned char *afterTreeHash;
38+
@property (nonatomic, readonly, nullable) NSDate *bundleCreationDate;
3839

3940
@end
4041

Autoupdate/SPUSparkleDeltaArchive.m

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,19 @@ - (nullable SPUDeltaArchiveHeader *)readHeader
348348
return nil;
349349
}
350350

351-
return [[SPUDeltaArchiveHeader alloc] initWithCompression:compression compressionLevel:metadata.compressionLevel fileSystemCompression:metadata.fileSystemCompression majorVersion:majorVersion minorVersion:minorVersion beforeTreeHash:beforeTreeHash afterTreeHash:afterTreeHash];
351+
NSDate *bundleCreationDate;
352+
if (MAJOR_VERSION_IS_AT_LEAST(majorVersion, SUBinaryDeltaMajorVersion4)) {
353+
double bundleCreationTimeInterval = 0;
354+
if (![self _readBuffer:&bundleCreationTimeInterval length:sizeof(bundleCreationTimeInterval)]) {
355+
return nil;
356+
}
357+
358+
bundleCreationDate = (bundleCreationTimeInterval != 0.0) ? [NSDate dateWithTimeIntervalSinceReferenceDate:bundleCreationTimeInterval] : nil;
359+
} else {
360+
bundleCreationDate = nil;
361+
}
362+
363+
return [[SPUDeltaArchiveHeader alloc] initWithCompression:compression compressionLevel:metadata.compressionLevel fileSystemCompression:metadata.fileSystemCompression majorVersion:majorVersion minorVersion:minorVersion beforeTreeHash:beforeTreeHash afterTreeHash:afterTreeHash bundleCreationDate:bundleCreationDate];
352364
}
353365

354366
- (NSArray<NSString *> *)_readRelativeFilePaths SPU_OBJC_DIRECT
@@ -855,6 +867,14 @@ - (void)writeHeader:(SPUDeltaArchiveHeader *)header
855867

856868
[self _writeBuffer:header.beforeTreeHash length:CC_SHA1_DIGEST_LENGTH];
857869
[self _writeBuffer:header.afterTreeHash length:CC_SHA1_DIGEST_LENGTH];
870+
871+
if (MAJOR_VERSION_IS_AT_LEAST(majorVersion, SUBinaryDeltaMajorVersion4)) {
872+
NSDate *bundleCreationDate = header.bundleCreationDate;
873+
874+
// If bundleCreationDate == nil, we will write out a 0 time interval
875+
double timeInterval = bundleCreationDate.timeIntervalSinceReferenceDate;
876+
[self _writeBuffer:&timeInterval length:sizeof(timeInterval)];
877+
}
858878
}
859879

860880
- (void)addItem:(SPUDeltaArchiveItem *)item
@@ -883,7 +903,7 @@ - (void)finishEncodingItems
883903

884904
// Clone commands reference relative file paths in this table but sometimes there may not
885905
// be an entry if extraction for an original item was skipped. Fill out any missing file path entries.
886-
// For example, if A.app has Contents/A and B.app has Contents/A and Contents/A and Contents/B,
906+
// For example, if A.app has Contents/A and B.app has Contents/A and Contents/B,
887907
// where A and B's contents are the same and A is the same in both apps, normally we would not record Contents/A because its extraction was skipped. However now B is a clone of A so we need a record for A.
888908
NSMutableArray<NSString *> *newClonedPathEntries = [NSMutableArray array];
889909
for (SPUDeltaArchiveItem *item in writableItems) {

Autoupdate/SPUXarDeltaArchive.m

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ - (nullable SPUDeltaArchiveHeader *)readHeader
167167

168168
// I wasn't able to figure out how to retrieve the compression options from xar,
169169
// so we will use default flags to indicate the info isn't available
170-
return [[SPUDeltaArchiveHeader alloc] initWithCompression:SPUDeltaCompressionModeDefault compressionLevel:0 fileSystemCompression:false majorVersion:majorDiffVersion minorVersion:minorDiffVersion beforeTreeHash:rawExpectedBeforeHash afterTreeHash:rawExpectedAfterHash];
170+
return [[SPUDeltaArchiveHeader alloc] initWithCompression:SPUDeltaCompressionModeDefault compressionLevel:0 fileSystemCompression:false majorVersion:majorDiffVersion minorVersion:minorDiffVersion beforeTreeHash:rawExpectedBeforeHash afterTreeHash:rawExpectedAfterHash bundleCreationDate:nil];
171171
}
172172

173173
- (void)writeHeader:(SPUDeltaArchiveHeader *)header

Autoupdate/SUBinaryDeltaApply.m

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,16 @@ BOOL applyBinaryDelta(NSString *source, NSString *finalDestination, NSString *pa
152152
}
153153
return NO;
154154
}
155+
156+
// Preserve file creation date only for the root item if the date is recorded
157+
// (requires major version 4 or later)
158+
NSDate *bundleCreationDate = header.bundleCreationDate;
159+
if (bundleCreationDate != nil) {
160+
NSError *setFileCreationDateError = nil;
161+
if (![fileManager setAttributes:@{NSFileCreationDate: bundleCreationDate} ofItemAtPath:destination error:&setFileCreationDateError]) {
162+
fprintf(stderr, "\nWarning: failed to set file creation date: %s", setFileCreationDateError.localizedDescription.UTF8String);
163+
}
164+
}
155165

156166
progressCallback(4/7.0);
157167

Autoupdate/SUBinaryDeltaCommon.h

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,17 @@ typedef NS_ENUM(uint16_t, SUBinaryDeltaMajorVersion)
4444
// Note: support for creating or applying version 1 deltas have been removed
4545
SUBinaryDeltaMajorVersion1 = 1,
4646
SUBinaryDeltaMajorVersion2 = 2,
47-
SUBinaryDeltaMajorVersion3 = 3
47+
SUBinaryDeltaMajorVersion3 = 3,
48+
SUBinaryDeltaMajorVersion4 = 4,
4849
};
4950

5051
extern SUBinaryDeltaMajorVersion SUBinaryDeltaMajorVersionDefault;
5152
extern SUBinaryDeltaMajorVersion SUBinaryDeltaMajorVersionLatest;
5253
extern SUBinaryDeltaMajorVersion SUBinaryDeltaMajorVersionFirst;
5354
extern SUBinaryDeltaMajorVersion SUBinaryDeltaMajorVersionFirstSupported;
5455

55-
// Additional compression methods for version 3 patches that we have for debugging are zlib, bzip2, none
56-
#define COMPRESSION_METHOD_ARGUMENT_DESCRIPTION @"The compression method to use for generating delta updates. Supported methods for version 3 delta files are 'lzma' (best compression, slowest), 'lzfse' (good compression, fast), 'lz4' (worse compression, fastest), and 'default'. Note that version 2 delta files only support 'bzip2', and 'default' so other methods will be ignored if version 2 files are being generated. The 'default' compression for version 3 delta files is currently lzma."
56+
// Additional compression methods for version 3 or 4 patches that we have for debugging are zlib, bzip2, none
57+
#define COMPRESSION_METHOD_ARGUMENT_DESCRIPTION @"The compression method to use for generating delta updates. Supported methods for version 3 delta files are 'lzma' (best compression, slowest), 'lzfse' (good compression, fast), 'lz4' (worse compression, fastest), and 'default'. Note that version 2 delta files only support 'bzip2', and 'default' so other methods will be ignored if version 2 files are being generated. The 'default' compression for version 3 or 4 delta files is currently lzma."
5758

5859
//#define COMPRESSION_LEVEL_ARGUMENT_DESCRIPTION @"The compression level to use for generating delta updates. This only applies if the compression method used is bzip2 which accepts values from 1 - 9. A special value of 0 will use the default compression level."
5960

Autoupdate/SUBinaryDeltaCommon.m

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
// Note: the framework bundle version must be bumped, and generate_appcast must be updated to compare it,
2323
// when we add/change new major versions and defaults. Unit tests need to be updated to use new versions too.
2424
SUBinaryDeltaMajorVersion SUBinaryDeltaMajorVersionDefault = SUBinaryDeltaMajorVersion3;
25-
SUBinaryDeltaMajorVersion SUBinaryDeltaMajorVersionLatest = SUBinaryDeltaMajorVersion3;
25+
SUBinaryDeltaMajorVersion SUBinaryDeltaMajorVersionLatest = SUBinaryDeltaMajorVersion4;
2626
SUBinaryDeltaMajorVersion SUBinaryDeltaMajorVersionFirst = SUBinaryDeltaMajorVersion1;
2727
SUBinaryDeltaMajorVersion SUBinaryDeltaMajorVersionFirstSupported = SUBinaryDeltaMajorVersion2;
2828

@@ -115,6 +115,8 @@ uint16_t latestMinorVersionForMajorVersion(SUBinaryDeltaMajorVersion majorVersio
115115
return 4;
116116
case SUBinaryDeltaMajorVersion3:
117117
return 1;
118+
case SUBinaryDeltaMajorVersion4:
119+
return 0;
118120
}
119121
return 0;
120122
}

Autoupdate/SUBinaryDeltaCreate.m

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -723,7 +723,23 @@ BOOL createBinaryDelta(NSString *source, NSString *destination, NSString *patchF
723723
#endif
724724
}
725725

726-
SPUDeltaArchiveHeader *header = [[SPUDeltaArchiveHeader alloc] initWithCompression:compression compressionLevel:compressionLevel fileSystemCompression:foundFilesystemCompression majorVersion:majorVersion minorVersion:minorVersion beforeTreeHash:beforeHash afterTreeHash:afterHash];
726+
// Record creation date of root bundle item
727+
NSDate *bundleCreationDate;
728+
if (MAJOR_VERSION_IS_AT_LEAST(majorVersion, SUBinaryDeltaMajorVersion4)) {
729+
NSError *fileAttributesError = nil;
730+
NSDictionary<NSFileAttributeKey, id> *fileAttributes = [[NSFileManager defaultManager] attributesOfItemAtPath:destination error:&fileAttributesError];
731+
732+
if (fileAttributes != nil) {
733+
bundleCreationDate = fileAttributes[NSFileCreationDate];
734+
} else {
735+
bundleCreationDate = nil;
736+
fprintf(stderr, "\nWarning: unable to retrieve file creation date of new bundle: %s", fileAttributesError.localizedDescription.UTF8String);
737+
}
738+
} else {
739+
bundleCreationDate = nil;
740+
}
741+
742+
SPUDeltaArchiveHeader *header = [[SPUDeltaArchiveHeader alloc] initWithCompression:compression compressionLevel:compressionLevel fileSystemCompression:foundFilesystemCompression majorVersion:majorVersion minorVersion:minorVersion beforeTreeHash:beforeHash afterTreeHash:afterHash bundleCreationDate:bundleCreationDate];
727743

728744
[archive writeHeader:header];
729745
if (archive.error != nil) {

Tests/SUBinaryDeltaTest.m

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -101,29 +101,32 @@ - (BOOL)createAndApplyPatchWithBeforeDiffHandler:(SUDeltaHandler)beforeDiffHandl
101101
#else
102102
BOOL testingVersion2Delta = NO;
103103
#endif
104-
return [self createAndApplyPatchWithBeforeDiffHandler:beforeDiffHandler afterDiffHandler:afterDiffHandler afterPatchHandler:afterPatchHandler testingVersion2Delta:testingVersion2Delta];
104+
return [self createAndApplyPatchWithBeforeDiffHandler:beforeDiffHandler afterDiffHandler:afterDiffHandler afterPatchHandler:afterPatchHandler testingVersion3Delta:YES testingVersion2Delta:testingVersion2Delta];
105105
}
106106

107-
- (BOOL)createAndApplyPatchWithBeforeDiffHandler:(SUDeltaHandler)beforeDiffHandler afterDiffHandler:(SUDeltaHandler)afterDiffHandler afterPatchHandler:(SUDeltaHandler)afterPatchHandler testingVersion2Delta:(BOOL)testingVersion2Delta
107+
- (BOOL)createAndApplyPatchWithBeforeDiffHandler:(SUDeltaHandler)beforeDiffHandler afterDiffHandler:(SUDeltaHandler)afterDiffHandler afterPatchHandler:(SUDeltaHandler)afterPatchHandler testingVersion3Delta:(BOOL)testingVersion3Delta testingVersion2Delta:(BOOL)testingVersion2Delta
108108
{
109-
XCTAssertEqual(SUBinaryDeltaMajorVersion3, SUBinaryDeltaMajorVersionLatest);
109+
XCTAssertEqual(SUBinaryDeltaMajorVersion4, SUBinaryDeltaMajorVersionLatest);
110110

111-
BOOL version3DeltaFormatWithLZMASuccess = [self createAndApplyPatchUsingVersion:SUBinaryDeltaMajorVersion3 compressionMode:SPUDeltaCompressionModeLZMA beforeDiffHandler:beforeDiffHandler afterDiffHandler:afterDiffHandler afterPatchHandler:afterPatchHandler];
111+
BOOL version4DeltaFormatWithLZMASuccess = [self createAndApplyPatchUsingVersion:SUBinaryDeltaMajorVersion4 compressionMode:SPUDeltaCompressionModeLZMA beforeDiffHandler:beforeDiffHandler afterDiffHandler:afterDiffHandler afterPatchHandler:afterPatchHandler];
112112

113113
#if SPARKLE_BUILD_BZIP2_DELTA_SUPPORT
114-
BOOL version3DeltaFormatWithBZIP2Success = [self createAndApplyPatchUsingVersion:SUBinaryDeltaMajorVersion3 compressionMode:SPUDeltaCompressionModeBzip2 beforeDiffHandler:beforeDiffHandler afterDiffHandler:afterDiffHandler afterPatchHandler:afterPatchHandler];
114+
BOOL version4DeltaFormatWithBZIP2Success = [self createAndApplyPatchUsingVersion:SUBinaryDeltaMajorVersion4 compressionMode:SPUDeltaCompressionModeBzip2 beforeDiffHandler:beforeDiffHandler afterDiffHandler:afterDiffHandler afterPatchHandler:afterPatchHandler];
115115
#endif
116116

117-
BOOL version3DeltaFormatWithZLIBSuccess = [self createAndApplyPatchUsingVersion:SUBinaryDeltaMajorVersion3 compressionMode:SPUDeltaCompressionModeZLIB beforeDiffHandler:beforeDiffHandler afterDiffHandler:afterDiffHandler afterPatchHandler:afterPatchHandler];
117+
BOOL version4DeltaFormatWithZLIBSuccess = [self createAndApplyPatchUsingVersion:SUBinaryDeltaMajorVersion4 compressionMode:SPUDeltaCompressionModeZLIB beforeDiffHandler:beforeDiffHandler afterDiffHandler:afterDiffHandler afterPatchHandler:afterPatchHandler];
118+
119+
BOOL version3DeltaFormatWithLZMASuccess = !testingVersion3Delta || [self createAndApplyPatchUsingVersion:SUBinaryDeltaMajorVersion3 compressionMode:SPUDeltaCompressionModeLZMA beforeDiffHandler:beforeDiffHandler afterDiffHandler:afterDiffHandler afterPatchHandler:afterPatchHandler];
118120

119121
BOOL version2FormatSuccess = !testingVersion2Delta || [self createAndApplyPatchUsingVersion:SUBinaryDeltaMajorVersion2 compressionMode:SPUDeltaCompressionModeDefault beforeDiffHandler:beforeDiffHandler afterDiffHandler:afterDiffHandler afterPatchHandler:afterPatchHandler];
120122

121123
return (
122-
version3DeltaFormatWithLZMASuccess &&
124+
version4DeltaFormatWithLZMASuccess &&
123125
#if SPARKLE_BUILD_BZIP2_DELTA_SUPPORT
124-
version3DeltaFormatWithBZIP2Success &&
126+
version4DeltaFormatWithBZIP2Success &&
125127
#endif
126-
version3DeltaFormatWithZLIBSuccess &&
128+
version4DeltaFormatWithZLIBSuccess &&
129+
version3DeltaFormatWithLZMASuccess &&
127130
version2FormatSuccess
128131
);
129132
}
@@ -1001,7 +1004,7 @@ - (void)testAddingSymlinkWithWrongPermissions
10011004
// Test that we only respect valid symlink permissions for >= version 3 deltas
10021005
unsigned short permissions = permissionAttribute.unsignedShortValue & PERMISSION_FLAGS;
10031006
XCTAssertEqual(permissions, VALID_SYMBOLIC_LINK_PERMISSIONS);
1004-
} testingVersion2Delta:NO];
1007+
} testingVersion3Delta:YES testingVersion2Delta:NO];
10051008
}
10061009

10071010
- (void)testSmallFilePermissionChangeWithNoContentChange
@@ -1410,7 +1413,7 @@ - (void)testFileSystemCompression
14101413
XCTFail(@"Second destination file is not compressed!");
14111414
}
14121415
}
1413-
} testingVersion2Delta:NO];
1416+
} testingVersion3Delta:YES testingVersion2Delta:NO];
14141417
}
14151418

14161419
- (void)testNoFileSystemCompression
@@ -1455,7 +1458,7 @@ - (void)testNoFileSystemCompression
14551458
XCTFail(@"Second destination file is compressed!");
14561459
}
14571460
}
1458-
} testingVersion2Delta:NO];
1461+
} testingVersion3Delta:YES testingVersion2Delta:NO];
14591462
}
14601463

14611464
- (void)testFrameworkVersionChanged
@@ -2207,4 +2210,45 @@ - (void)testInvalidSparkleFrameworkInAfterTree
22072210
XCTAssertFalse(success);
22082211
}
22092212

2213+
- (void)testBundleCreationDate
2214+
{
2215+
NSDate *sourceDate = [NSDate dateWithTimeIntervalSinceReferenceDate:420111117.0];
2216+
NSDate *destinationDate = [NSDate dateWithTimeIntervalSinceReferenceDate:530112117.0];
2217+
2218+
BOOL success = [self createAndApplyPatchWithBeforeDiffHandler:^(NSFileManager *fileManager, NSString *sourceDirectory, NSString *destinationDirectory) {
2219+
NSString *sourceFile = [sourceDirectory stringByAppendingPathComponent:@"A"];
2220+
NSString *destinationFile = [destinationDirectory stringByAppendingPathComponent:@"A"];
2221+
2222+
XCTAssertTrue([[NSData data] writeToFile:sourceFile atomically:YES]);
2223+
XCTAssertTrue([[NSData dataWithBytes:"loltest" length:7] writeToFile:destinationFile atomically:YES]);
2224+
2225+
{
2226+
NSError *setFileCreationDateError = nil;
2227+
if (![fileManager setAttributes:@{NSFileCreationDate: sourceDate} ofItemAtPath:sourceDirectory error:&setFileCreationDateError]) {
2228+
XCTFail(@"Failed to modify file creation date for source directory: %@", setFileCreationDateError.localizedDescription);
2229+
}
2230+
}
2231+
2232+
{
2233+
NSError *setFileCreationDateError = nil;
2234+
if (![fileManager setAttributes:@{NSFileCreationDate: destinationDate} ofItemAtPath:destinationDirectory error:&setFileCreationDateError]) {
2235+
XCTFail(@"Failed to modify file creation date for destination directory: %@", setFileCreationDateError.localizedDescription);
2236+
}
2237+
}
2238+
} afterDiffHandler:nil afterPatchHandler:^(NSFileManager *fileManager, NSString * __unused sourceDirectory, NSString *destinationDirectory) {
2239+
NSError *fileAttributesError = nil;
2240+
NSDictionary<NSFileAttributeKey, id> *fileAttributes = [fileManager attributesOfItemAtPath:destinationDirectory error:&fileAttributesError];
2241+
2242+
if (fileAttributes == nil) {
2243+
XCTFail(@"Failed to retrieve file attributes from destination directory: %@", fileAttributesError.localizedDescription);
2244+
}
2245+
2246+
NSDate *fileCreationDate = fileAttributes[NSFileCreationDate];
2247+
XCTAssertNotNil(fileCreationDate);
2248+
2249+
XCTAssertEqualObjects(destinationDate, fileCreationDate);
2250+
} testingVersion3Delta:NO testingVersion2Delta:NO];
2251+
XCTAssertTrue(success);
2252+
}
2253+
22102254
@end

0 commit comments

Comments
 (0)