@algocare/react-native-code-push 12.6.2 → 12.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,78 @@
1
+ # npm 배포
2
+
3
+ 인자: $ARGUMENTS (patch, minor, major 중 하나. 기본값: patch)
4
+
5
+ 아래 단계를 순서대로 실행하세요. 각 단계 완료 후 다음 단계로 넘어가세요.
6
+
7
+ ## 1단계: 사전 검증
8
+
9
+ 다음을 모두 확인하세요:
10
+ - `git status`: working tree가 clean한지
11
+ - `git branch --show-current`: master 브랜치인지
12
+ - `npm whoami`: npm 로그인 상태인지
13
+
14
+ 로그인이 안 되어 있으면 사용자에게 안내:
15
+ ```
16
+ npm 로그인이 필요합니다.
17
+ 계정 정보: https://www.notion.so/algocare/npm-1d59954bcc7b8056b6b6c84612592266
18
+ 터미널에서 실행: npm login --scope=@algocare
19
+ ```
20
+ 로그인 완료 후 다시 확인하세요.
21
+
22
+ 검증 실패 시 해당 문제를 안내하고 중단하세요.
23
+
24
+ ## 2단계: 버전 bump + commit + tag
25
+
26
+ ```bash
27
+ npm version $ARGUMENTS --no-git-tag-version
28
+ ```
29
+
30
+ package.json만 변경됩니다. 이후:
31
+ ```bash
32
+ git add package.json package-lock.json
33
+ git commit -m "v{새버전}"
34
+ git tag "v{새버전}"
35
+ ```
36
+
37
+ ## 3단계: npm publish (사용자 터미널 필요)
38
+
39
+ npm OTP 브라우저 인증이 필요하므로 Claude Code에서 직접 실행할 수 없습니다.
40
+
41
+ 사용자에게 다음을 안내하세요:
42
+ ```
43
+ 터미널에서 실행해주세요:
44
+ npm publish --access public --ignore-scripts
45
+
46
+ 브라우저 인증 완료 후 알려주세요.
47
+ ```
48
+
49
+ 사용자가 완료했다고 하면 배포 확인:
50
+ ```bash
51
+ npm view @algocare/react-native-code-push version
52
+ ```
53
+
54
+ 배포된 버전이 새 버전과 일치하면 다음 단계로. 실패 시 복구 안내:
55
+ ```
56
+ 복구: git tag -d v{새버전} && git reset --soft HEAD~1 && git checkout package.json
57
+ ```
58
+
59
+ ## 4단계: git push + GitHub Release
60
+
61
+ ```bash
62
+ git push && git push --tags
63
+ ```
64
+
65
+ 이후 GitHub Release 생성:
66
+ ```bash
67
+ PREV_TAG=$(git tag --sort=-v:refname | sed -n '2p')
68
+ # PREV_TAG..새태그 사이의 커밋으로 릴리스 노트 생성
69
+ gh release create "v{새버전}" --title "{새버전}" --notes "{릴리스노트}"
70
+ ```
71
+
72
+ ## 5단계: 완료 보고
73
+
74
+ 배포 결과를 요약하세요:
75
+ - 패키지: @algocare/react-native-code-push@{새버전}
76
+ - npm: 배포 완료
77
+ - git: push 완료
78
+ - GitHub Release: 생성 완료
@@ -0,0 +1,89 @@
1
+ ---
2
+ name: npm-release
3
+ description: Use when user asks to publish, release, or deploy an npm package. Triggers on "배포", "publish", "release", "npm 배포", "버전 올려". Guides step-by-step npm publish workflow with OTP browser auth handoff.
4
+ ---
5
+
6
+ # npm Release
7
+
8
+ ## Overview
9
+
10
+ npm 패키지 배포를 단계별로 수행한다. OTP 브라우저 인증이 필요한 `npm publish`는 사용자 터미널로 넘기고, 나머지는 자동 처리한다.
11
+
12
+ ## When to Use
13
+
14
+ - 사용자가 "배포해줘", "publish 해줘", "npm 배포", "버전 올려줘", "release 해줘" 등 요청
15
+ - 프로젝트에 package.json이 있고 npm 배포가 필요한 경우
16
+
17
+ ## When NOT to Use
18
+
19
+ - CodePush 번들 배포 (code-push CLI 사용)
20
+ - 앱스토어 배포
21
+
22
+ ## Workflow
23
+
24
+ ### 1. 사전 검증
25
+
26
+ 모두 확인:
27
+ - `git status`: clean working tree
28
+ - `git branch --show-current`: 기본 브랜치 (master/main)
29
+ - `npm whoami`: npm 로그인 상태
30
+
31
+ 로그인 안 됨 → 안내:
32
+ ```
33
+ npm 로그인이 필요합니다.
34
+ 터미널에서 실행: npm login
35
+ ```
36
+
37
+ 프로젝트에 release 스크립트나 `/release` 커맨드가 있으면 그것의 로그인 안내를 따른다.
38
+
39
+ ### 2. 버전 bump + commit + tag
40
+
41
+ ```bash
42
+ VERSION_TYPE=patch # 사용자 지정 시 minor/major
43
+ npm version $VERSION_TYPE --no-git-tag-version
44
+ git add package.json package-lock.json
45
+ git commit -m "v{새버전}"
46
+ git tag "v{새버전}"
47
+ ```
48
+
49
+ ### 3. npm publish (사용자 핸드오프)
50
+
51
+ **Claude Code에서 직접 실행 불가 (OTP 브라우저 인증 필요)**
52
+
53
+ 사용자에게 안내:
54
+ ```
55
+ 터미널에서 실행해주세요:
56
+ npm publish --access public --ignore-scripts
57
+
58
+ 브라우저 인증 완료 후 알려주세요.
59
+ ```
60
+
61
+ 사용자 완료 후 확인:
62
+ ```bash
63
+ npm view {패키지명} version
64
+ ```
65
+
66
+ 실패 시 복구 안내:
67
+ ```
68
+ 복구: git tag -d v{새버전} && git reset --soft HEAD~1 && git checkout package.json
69
+ ```
70
+
71
+ ### 4. git push + GitHub Release
72
+
73
+ ```bash
74
+ git push && git push --tags
75
+ ```
76
+
77
+ GitHub Release 생성 (이전 태그 이후 커밋 기반 노트).
78
+
79
+ ### 5. 완료 보고
80
+
81
+ 패키지명, 버전, npm/git/GitHub Release 상태 요약.
82
+
83
+ ## Common Mistakes
84
+
85
+ | 실수 | 해결 |
86
+ |------|------|
87
+ | Claude Code에서 npm publish 직접 실행 | OTP URL이 마스킹됨. 반드시 사용자 터미널로 넘길 것 |
88
+ | package.json에 `"publish"` lifecycle script | `--ignore-scripts`로 재귀 호출 방지 |
89
+ | publish 에러 코드만 보고 실패 판정 | `npm view`로 실제 배포 여부 재확인 |
package/CLAUDE.md ADDED
@@ -0,0 +1,83 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Overview
6
+
7
+ Microsoft react-native-code-push의 알고케어 포크. AWS S3/CDN 기반 자체 CodePush 배포 시스템으로, 네이티브 모듈(iOS/Android)이 런타임에 JS 번들 업데이트를 확인·다운로드·설치하고, CLI가 번들 생성·업로드·히스토리 관리를 담당한다.
8
+
9
+ ## Commands
10
+
11
+ ```bash
12
+ # 테스트
13
+ npm run jest # versioning 단위 테스트 (Jest)
14
+ npm test # 전체 테스트 (빌드 → 셋업 → 실행)
15
+ npm run test:ios # iOS 통합 테스트
16
+ npm run test:android # Android 통합 테스트
17
+
18
+ # 린트
19
+ npm run eslint # ESLint (eslint.config.mjs, 플랫 설정)
20
+ npm run tslint # TSLint (test/ 디렉토리)
21
+
22
+ # 빌드
23
+ npm run build:tests # tsc로 테스트 빌드
24
+
25
+ # npm 배포 — `/release` 커맨드 또는 "배포해줘"로 실행
26
+ # 상세 워크플로우: .claude/commands/release.md 참조
27
+ npm run release # patch (기본)
28
+ npm run release -- minor # minor
29
+ npm run release -- major # major
30
+ ```
31
+
32
+ ## Architecture
33
+
34
+ ### JS ↔ Native 브릿지
35
+
36
+ ```
37
+ CodePush.js (진입점, sync/checkForUpdate/HOC)
38
+ └─ package-mixins.js (remote.download, local.install)
39
+ └─ NativeCodePush (RCT_EXPORT_METHOD)
40
+ ├─ iOS: CodePush.m → CodePushPackage.m → CodePushDownloadHandler.m
41
+ └─ Android: CodePushNativeModule.java → CodePushUpdateManager.java
42
+ ```
43
+
44
+ - `CodePush.js`: `sync()`, `checkForUpdate()`, SyncStatus/InstallMode 상수, React HOC (`codePush()`)
45
+ - `package-mixins.js`: 네이티브 이벤트(`CodePushDownloadProgress`, `CodePushDownloadComplete`) 구독하여 다운로드 진행률 전달
46
+ - 네이티브 모듈은 `RCT_EXPORT_METHOD`로 JS에 노출, `NativeEventEmitter`로 이벤트 전달
47
+
48
+ ### 업데이트 흐름
49
+
50
+ 1. `checkForUpdate()` → `releaseHistoryFetcher`로 릴리스 히스토리 JSON 조회
51
+ 2. `versioning/` 모듈이 버전 비교·필수 업데이트·롤백 판단 (`SemverVersioning` 기본)
52
+ 3. `remotePackage.download()` → 네이티브 다운로드 → unzip → `LocalPackage` 반환
53
+ 4. `localPackage.install(installMode)` → InstallMode에 따라 즉시/다음 재시작 시 적용
54
+
55
+ ### CLI (`cli/index.js`, `code-push` 명령)
56
+
57
+ 주요 명령: `bundle`, `release`, `create-history`, `update-history`, `show-history`, `get-package-hash`
58
+
59
+ `code-push.config.ts`에서 앱별(user, device, b2c-device, home-device, home-mobile) S3 경로·CDN URL·환경(STG/PRD) 설정.
60
+
61
+ ### 앱별 설정
62
+
63
+ | 앱 | 타입 | 플랫폼 | 환경변수 |
64
+ |---|---|---|---|
65
+ | user | B2B | iOS/Android | Infisical |
66
+ | device | B2B | Android | Infisical |
67
+ | b2c-device | B2C MVP | Android | Infisical |
68
+ | home-device | B2C Home | Android | .env |
69
+ | home-mobile | B2C Home | iOS/Android | .env |
70
+
71
+ ### Versioning (`versioning/`)
72
+
73
+ - `BaseVersioning.js` - 릴리스 히스토리 정렬/필터/롤백 로직 (추상)
74
+ - `SemverVersioning.js` - Semver 기반 (기본값)
75
+ - `IncrementalVersioning.js` - 증분 버전 관리
76
+
77
+ ## Key Conventions
78
+
79
+ - 패키지: `@algocare/react-native-code-push` (npm scoped, public)
80
+ - iOS 네이티브: Objective-C, `NSURLSession` 기반 다운로드, `SSZipArchive`로 unzip
81
+ - Android 네이티브: Java, `com.microsoft.codepush.react` 패키지
82
+ - TypeScript 타입: `typings/react-native-code-push.d.ts`
83
+ - CocoaPods: `CodePush.podspec`
package/CodePush.js CHANGED
@@ -586,6 +586,9 @@ async function syncInternal(
586
586
  }
587
587
  }
588
588
  break
589
+ case CodePush.SyncStatus.PREPARING_UPDATE:
590
+ log('Download complete. Preparing update (unzip/file-copy).')
591
+ break
589
592
  case CodePush.SyncStatus.UNKNOWN_ERROR:
590
593
  log('An unknown error occurred.')
591
594
  break
@@ -608,7 +611,11 @@ async function syncInternal(
608
611
  const doDownloadAndInstall = async () => {
609
612
  syncStatusChangeCallback(CodePush.SyncStatus.DOWNLOADING_PACKAGE)
610
613
  const localPackage = await remotePackage.download(
611
- downloadProgressCallback
614
+ downloadProgressCallback,
615
+ () => {
616
+ // HTTP download complete, post-processing (unzip/file-copy) starts
617
+ syncStatusChangeCallback(CodePush.SyncStatus.PREPARING_UPDATE)
618
+ }
612
619
  )
613
620
 
614
621
  // Determine the correct install mode based on whether the update is mandatory or not.
@@ -924,6 +931,7 @@ if (NativeCodePush) {
924
931
  AWAITING_USER_ACTION: 6,
925
932
  DOWNLOADING_PACKAGE: 7,
926
933
  INSTALLING_UPDATE: 8,
934
+ PREPARING_UPDATE: 9, // HTTP download complete, post-processing (unzip/file-copy) in progress
927
935
  },
928
936
  CheckFrequency: {
929
937
  ON_APP_START: 0,
@@ -108,9 +108,9 @@
108
108
 
109
109
  @end
110
110
 
111
- @interface CodePushDownloadHandler : NSObject <NSURLConnectionDelegate>
111
+ @interface CodePushDownloadHandler : NSObject <NSURLSessionDownloadDelegate>
112
112
 
113
- @property (strong) NSOutputStream *outputFileStream;
113
+ @property (strong) NSString *downloadFilePath;
114
114
  @property long long expectedContentLength;
115
115
  @property long long receivedContentLength;
116
116
  @property dispatch_queue_t operationQueue;
@@ -144,7 +144,9 @@ failCallback:(void (^)(NSError *err))failCallback;
144
144
  operationQueue:(dispatch_queue_t)operationQueue
145
145
  progressCallback:(void (^)(long long, long long))progressCallback
146
146
  doneCallback:(void (^)())doneCallback
147
- failCallback:(void (^)(NSError *err))failCallback;
147
+ failCallback:(void (^)(NSError *err))failCallback
148
+ installProgressCallback:(void (^)(NSString *, NSNumber *, NSNumber *))installProgressCallback
149
+ downloadCompleteCallback:(void (^)())downloadCompleteCallback;
148
150
 
149
151
  + (NSDictionary *)getCurrentPackage:(NSError **)error;
150
152
  + (NSDictionary *)getPreviousPackage:(NSError **)error;
@@ -43,6 +43,8 @@ RCT_EXPORT_MODULE()
43
43
 
44
44
  // These constants represent emitted events
45
45
  static NSString *const DownloadProgressEvent = @"CodePushDownloadProgress";
46
+ static NSString *const DownloadCompleteEvent = @"CodePushDownloadComplete";
47
+ static NSString *const InstallProgressEvent = @"CodePushInstallProgress";
46
48
 
47
49
  // These constants represent valid deployment statuses
48
50
  static NSString *const DeploymentFailed = @"DeploymentFailed";
@@ -640,7 +642,7 @@ static NSString *const LatestRollbackCountKey = @"count";
640
642
  }
641
643
 
642
644
  - (NSArray<NSString *> *)supportedEvents {
643
- return @[DownloadProgressEvent];
645
+ return @[DownloadProgressEvent, DownloadCompleteEvent, InstallProgressEvent];
644
646
  }
645
647
 
646
648
  // Determine how long the app was in the background
@@ -726,6 +728,17 @@ RCT_EXPORT_METHOD(downloadUpdate:(NSDictionary*)updatePackage
726
728
 
727
729
  NSString * publicKey = [[CodePushConfig current] publicKey];
728
730
 
731
+ // Install progress callback for reporting unzip/file-copy progress to JS
732
+ void (^installProgressCallback)(NSString *, NSNumber *, NSNumber *) =
733
+ ^(NSString *phase, NSNumber *current, NSNumber *total) {
734
+ dispatch_async(dispatch_get_main_queue(), ^{
735
+ [self sendEventWithName:InstallProgressEvent
736
+ body:@{@"phase": phase,
737
+ @"current": current,
738
+ @"total": total}];
739
+ });
740
+ };
741
+
729
742
  [CodePushPackage
730
743
  downloadPackage:mutableUpdatePackage
731
744
  expectedBundleFileName:[bundleResourceName stringByAppendingPathExtension:bundleResourceExtension]
@@ -746,7 +759,7 @@ RCT_EXPORT_METHOD(downloadUpdate:(NSDictionary*)updatePackage
746
759
  [self dispatchDownloadProgressEvent];
747
760
  }
748
761
  }
749
- // The download completed
762
+ // The download and post-processing completed
750
763
  doneCallback:^{
751
764
  NSError *err;
752
765
  NSDictionary *newPackage = [CodePushPackage getPackage:mutableUpdatePackage[PackageHashKey] error:&err];
@@ -766,6 +779,14 @@ RCT_EXPORT_METHOD(downloadUpdate:(NSDictionary*)updatePackage
766
779
  _didUpdateProgress = NO;
767
780
  self.paused = YES;
768
781
  reject([NSString stringWithFormat: @"%lu", (long)err.code], err.localizedDescription, err);
782
+ }
783
+ // Install progress (unzip/file-copy) callback
784
+ installProgressCallback:installProgressCallback
785
+ // Notify JS that HTTP download is complete and post-processing begins
786
+ downloadCompleteCallback:^{
787
+ dispatch_async(dispatch_get_main_queue(), ^{
788
+ [self sendEventWithName:DownloadCompleteEvent body:@{}];
789
+ });
769
790
  }];
770
791
  }
771
792
 
@@ -1,8 +1,10 @@
1
1
  #import "CodePush.h"
2
2
 
3
+ static const NSTimeInterval kDownloadTimeoutInterval = 60.0;
4
+
3
5
  @implementation CodePushDownloadHandler {
4
- // Header chars used to determine if the file is a zip.
5
- char _header[4];
6
+ NSURLSession *_session;
7
+ BOOL _completed; // 콜백 중복 호출 방지 플래그
6
8
  }
7
9
 
8
10
  - (id)init:(NSString *)downloadFilePath
@@ -10,121 +12,141 @@ operationQueue:(dispatch_queue_t)operationQueue
10
12
  progressCallback:(void (^)(long long, long long))progressCallback
11
13
  doneCallback:(void (^)(BOOL))doneCallback
12
14
  failCallback:(void (^)(NSError *err))failCallback {
13
- self.outputFileStream = [NSOutputStream outputStreamToFileAtPath:downloadFilePath
14
- append:NO];
15
- self.receivedContentLength = 0;
16
- self.operationQueue = operationQueue;
17
- self.progressCallback = progressCallback;
18
- self.doneCallback = doneCallback;
19
- self.failCallback = failCallback;
15
+ self = [super init];
16
+ if (self) {
17
+ self.downloadFilePath = downloadFilePath;
18
+ self.receivedContentLength = 0;
19
+ self.expectedContentLength = 0;
20
+ self.operationQueue = operationQueue;
21
+ self.progressCallback = progressCallback;
22
+ self.doneCallback = doneCallback;
23
+ self.failCallback = failCallback;
24
+ _completed = NO;
25
+ }
20
26
  return self;
21
27
  }
22
28
 
23
29
  - (void)download:(NSString *)url {
24
30
  self.downloadUrl = url;
31
+ NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
32
+ config.timeoutIntervalForRequest = kDownloadTimeoutInterval;
33
+
34
+ NSOperationQueue *delegateQueue = [NSOperationQueue new];
35
+ delegateQueue.underlyingQueue = self.operationQueue;
36
+
37
+ _session = [NSURLSession sessionWithConfiguration:config
38
+ delegate:self
39
+ delegateQueue:delegateQueue];
40
+
25
41
  NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:url]
26
42
  cachePolicy:NSURLRequestUseProtocolCachePolicy
27
- timeoutInterval:60.0];
28
- NSURLConnection *connection = [[NSURLConnection alloc] initWithRequest:request
29
- delegate:self
30
- startImmediately:NO];
31
- if ([NSOperationQueue instancesRespondToSelector:@selector(setUnderlyingQueue:)]) {
32
- NSOperationQueue *delegateQueue = [NSOperationQueue new];
33
- delegateQueue.underlyingQueue = self.operationQueue;
34
- [connection setDelegateQueue:delegateQueue];
35
- } else {
36
- [connection scheduleInRunLoop:[NSRunLoop mainRunLoop]
37
- forMode:NSDefaultRunLoopMode];
38
- }
43
+ timeoutInterval:kDownloadTimeoutInterval];
44
+ NSURLSessionDownloadTask *task = [_session downloadTaskWithRequest:request];
45
+ [task resume];
46
+ }
39
47
 
40
- [connection start];
48
+ - (void)invokeFailCallback:(NSError *)error {
49
+ if (_completed) return;
50
+ _completed = YES;
51
+ if (self.failCallback) {
52
+ self.failCallback(error);
53
+ }
41
54
  }
42
55
 
43
- #pragma mark NSURLConnection Delegate Methods
56
+ - (void)invokeDoneCallback:(BOOL)isZip {
57
+ if (_completed) return;
58
+ _completed = YES;
59
+ if (self.doneCallback) {
60
+ self.doneCallback(isZip);
61
+ }
62
+ }
44
63
 
45
- - (NSCachedURLResponse *)connection:(NSURLConnection *)connection
46
- willCacheResponse:(NSCachedURLResponse*)cachedResponse {
47
- // Return nil to indicate not necessary to store a cached response for this connection
48
- return nil;
64
+ #pragma mark - NSURLSessionDownloadDelegate
65
+
66
+ - (void)URLSession:(NSURLSession *)session
67
+ downloadTask:(NSURLSessionDownloadTask *)downloadTask
68
+ didWriteData:(int64_t)bytesWritten
69
+ totalBytesWritten:(int64_t)totalBytesWritten
70
+ totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite {
71
+ self.expectedContentLength = totalBytesExpectedToWrite;
72
+ self.receivedContentLength = totalBytesWritten;
73
+ if (self.progressCallback) {
74
+ self.progressCallback(self.expectedContentLength, self.receivedContentLength);
75
+ }
49
76
  }
50
77
 
51
- - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response {
52
- if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
53
- NSInteger statusCode = [(NSHTTPURLResponse *)response statusCode];
78
+ - (void)URLSession:(NSURLSession *)session
79
+ downloadTask:(NSURLSessionDownloadTask *)downloadTask
80
+ didFinishDownloadingToURL:(NSURL *)location {
81
+ // Check HTTP status code
82
+ if ([downloadTask.response isKindOfClass:[NSHTTPURLResponse class]]) {
83
+ NSInteger statusCode = ((NSHTTPURLResponse *)downloadTask.response).statusCode;
54
84
  if (statusCode >= 400) {
55
- [self.outputFileStream close];
56
- [connection cancel];
57
- NSError *err = [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat: @"Received %ld response from %@", (long)statusCode, self.downloadUrl]];
58
- self.failCallback(err);
85
+ NSError *err = [CodePushErrorUtils errorWithMessage:
86
+ [NSString stringWithFormat:@"Received %ld response from %@",
87
+ (long)statusCode, self.downloadUrl]];
88
+ [self invokeFailCallback:err];
59
89
  return;
60
90
  }
61
91
  }
62
-
63
- self.expectedContentLength = response.expectedContentLength;
64
- [self.outputFileStream open];
65
- }
66
92
 
67
- - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {
68
- if (self.receivedContentLength < 4) {
69
- for (int i = 0; i < [data length]; i++) {
70
- int headerOffset = (int)self.receivedContentLength + i;
71
- if (headerOffset >= 4) {
72
- break;
73
- }
93
+ // Move downloaded file from temporary location to our target path
94
+ NSFileManager *fileManager = [NSFileManager defaultManager];
95
+ NSError *moveError = nil;
74
96
 
75
- const char *bytes = [data bytes];
76
- _header[headerOffset] = bytes[i];
77
- }
97
+ // Remove existing file if present
98
+ if ([fileManager fileExistsAtPath:self.downloadFilePath]) {
99
+ [fileManager removeItemAtPath:self.downloadFilePath error:nil];
78
100
  }
79
101
 
80
- self.receivedContentLength = self.receivedContentLength + [data length];
81
-
82
- NSInteger bytesLeft = [data length];
102
+ [fileManager moveItemAtURL:location
103
+ toURL:[NSURL fileURLWithPath:self.downloadFilePath]
104
+ error:&moveError];
83
105
 
84
- do {
85
- NSInteger bytesWritten = [self.outputFileStream write:[data bytes]
86
- maxLength:bytesLeft];
87
- if (bytesWritten == -1) {
88
- break;
89
- }
90
-
91
- bytesLeft -= bytesWritten;
92
- } while (bytesLeft > 0);
106
+ if (moveError) {
107
+ [self invokeFailCallback:moveError];
108
+ return;
109
+ }
93
110
 
94
- self.progressCallback(self.expectedContentLength, self.receivedContentLength);
111
+ if (self.receivedContentLength < 1) {
112
+ NSError *err = [CodePushErrorUtils errorWithMessage:
113
+ [NSString stringWithFormat:@"Received empty response from %@",
114
+ self.downloadUrl]];
115
+ [self invokeFailCallback:err];
116
+ return;
117
+ }
95
118
 
96
- // bytesLeft should not be negative.
97
- assert(bytesLeft >= 0);
119
+ // Determine if the downloaded file is a ZIP by reading its header
120
+ BOOL isZip = NO;
121
+ NSFileHandle *fileHandle = [NSFileHandle fileHandleForReadingAtPath:self.downloadFilePath];
122
+ if (fileHandle) {
123
+ NSData *headerData = [fileHandle readDataOfLength:4];
124
+ [fileHandle closeFile];
125
+ if (headerData.length >= 4) {
126
+ const char *bytes = [headerData bytes];
127
+ isZip = bytes[0] == 'P' && bytes[1] == 'K' && bytes[2] == 3 && bytes[3] == 4;
128
+ }
129
+ }
98
130
 
99
- if (bytesLeft) {
100
- [self.outputFileStream close];
101
- [connection cancel];
102
- self.failCallback([self.outputFileStream streamError]);
131
+ // Send final 100% progress callback
132
+ // Use receivedContentLength for both args to guarantee expected == received at completion
133
+ if (self.progressCallback) {
134
+ self.progressCallback(self.receivedContentLength, self.receivedContentLength);
103
135
  }
104
- }
105
136
 
106
- - (void)connection:(NSURLConnection*)connection didFailWithError:(NSError*)error
107
- {
108
- [self.outputFileStream close];
109
- self.failCallback(error);
137
+ [self invokeDoneCallback:isZip];
110
138
  }
111
139
 
112
- - (void)connectionDidFinishLoading:(NSURLConnection *)connection {
113
- [self.outputFileStream close];
114
- if (self.receivedContentLength < 1) {
115
- NSError *err = [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Received empty response from %@", self.downloadUrl]];
116
- self.failCallback(err);
117
- return;
118
- }
119
-
120
- // expectedContentLength might be -1 when NSURLConnection don't know the length(e.g. response encode with gzip)
121
- if (self.expectedContentLength > 0) {
122
- // We should have received all of the bytes if this is called.
123
- assert(self.receivedContentLength == self.expectedContentLength);
124
- }
140
+ #pragma mark - NSURLSessionTaskDelegate
125
141
 
126
- BOOL isZip = _header[0] == 'P' && _header[1] == 'K' && _header[2] == 3 && _header[3] == 4;
127
- self.doneCallback(isZip);
142
+ - (void)URLSession:(NSURLSession *)session
143
+ task:(NSURLSessionTask *)task
144
+ didCompleteWithError:(NSError *)error {
145
+ if (error) {
146
+ [self invokeFailCallback:error];
147
+ }
148
+ [session finishTasksAndInvalidate];
149
+ _session = nil; // Break retain cycle (NSURLSession strong-references its delegate)
128
150
  }
129
151
 
130
152
  @end
@@ -50,6 +50,8 @@ static NSString *const UnzippedFolderName = @"unzipped";
50
50
  progressCallback:(void (^)(long long, long long))progressCallback
51
51
  doneCallback:(void (^)())doneCallback
52
52
  failCallback:(void (^)(NSError *err))failCallback
53
+ installProgressCallback:(void (^)(NSString *, NSNumber *, NSNumber *))installProgressCallback
54
+ downloadCompleteCallback:(void (^)())downloadCompleteCallback
53
55
  {
54
56
  NSString *newUpdateHash = updatePackage[@"packageHash"];
55
57
  NSString *newUpdateFolderPath = [self getPackageFolderPath:newUpdateHash];
@@ -85,6 +87,11 @@ static NSString *const UnzippedFolderName = @"unzipped";
85
87
  operationQueue:operationQueue
86
88
  progressCallback:progressCallback
87
89
  doneCallback:^(BOOL isZip) {
90
+ // HTTP 다운로드 완료 → 후처리(unzip/파일복사) 시작 전 알림
91
+ if (downloadCompleteCallback) {
92
+ downloadCompleteCallback();
93
+ }
94
+
88
95
  NSError *error = nil;
89
96
  NSString * unzippedFolderPath = [CodePushPackage getUnzippedFolderPath];
90
97
  NSMutableDictionary * mutableUpdatePackage = [updatePackage mutableCopy];
@@ -101,8 +108,21 @@ static NSString *const UnzippedFolderName = @"unzipped";
101
108
  }
102
109
 
103
110
  NSError *nonFailingError = nil;
104
- [SSZipArchive unzipFileAtPath:downloadFilePath
105
- toDestination:unzippedFolderPath];
111
+ BOOL unzipSuccess = [SSZipArchive unzipFileAtPath:downloadFilePath
112
+ toDestination:unzippedFolderPath
113
+ overwrite:YES
114
+ password:nil
115
+ progressHandler:^(NSString *entry, unz_file_info zipInfo, long entryNumber, long total) {
116
+ if (installProgressCallback && total > 0) {
117
+ installProgressCallback(@"EXTRACTING", @(entryNumber), @(total));
118
+ }
119
+ }
120
+ completionHandler:nil];
121
+ if (!unzipSuccess) {
122
+ error = [CodePushErrorUtils errorWithMessage:@"Failed to unzip the downloaded update."];
123
+ failCallback(error);
124
+ return;
125
+ }
106
126
  [[NSFileManager defaultManager] removeItemAtPath:downloadFilePath
107
127
  error:&nonFailingError];
108
128
  if (nonFailingError) {
package/package-mixins.js CHANGED
@@ -7,19 +7,31 @@ import log from "./logging";
7
7
  module.exports = (NativeCodePush) => {
8
8
  const remote = () => {
9
9
  return {
10
- async download(downloadProgressCallback) {
10
+ async download(downloadProgressCallback, downloadCompleteCallback) {
11
11
  if (!this.downloadUrl) {
12
12
  throw new Error("Cannot download an update without a download url");
13
13
  }
14
14
 
15
15
  let downloadProgressSubscription;
16
- if (downloadProgressCallback) {
16
+ let downloadCompleteSubscription;
17
+ if (downloadProgressCallback || downloadCompleteCallback) {
17
18
  const codePushEventEmitter = new NativeEventEmitter(NativeCodePush);
18
- // Use event subscription to obtain download progress.
19
- downloadProgressSubscription = codePushEventEmitter.addListener(
20
- "CodePushDownloadProgress",
21
- downloadProgressCallback
22
- );
19
+
20
+ if (downloadProgressCallback) {
21
+ // Use event subscription to obtain download progress.
22
+ downloadProgressSubscription = codePushEventEmitter.addListener(
23
+ "CodePushDownloadProgress",
24
+ downloadProgressCallback
25
+ );
26
+ }
27
+
28
+ if (downloadCompleteCallback) {
29
+ // Listen for download complete event (HTTP download done, post-processing starts)
30
+ downloadCompleteSubscription = codePushEventEmitter.addListener(
31
+ "CodePushDownloadComplete",
32
+ downloadCompleteCallback
33
+ );
34
+ }
23
35
  }
24
36
 
25
37
  // Use the downloaded package info. Native code will save the package info
@@ -33,6 +45,7 @@ module.exports = (NativeCodePush) => {
33
45
  return { ...downloadedPackage, ...local };
34
46
  } finally {
35
47
  downloadProgressSubscription && downloadProgressSubscription.remove();
48
+ downloadCompleteSubscription && downloadCompleteSubscription.remove();
36
49
  }
37
50
  },
38
51
 
@@ -58,4 +71,4 @@ module.exports = (NativeCodePush) => {
58
71
  };
59
72
 
60
73
  return { local, remote };
61
- };
74
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@algocare/react-native-code-push",
3
- "version": "12.6.2",
3
+ "version": "12.6.4",
4
4
  "description": "React Native plugin for the CodePush service",
5
5
  "main": "CodePush.js",
6
6
  "typings": "typings/react-native-code-push.d.ts",
@@ -32,7 +32,7 @@
32
32
  "test:debugger:android": "mocha --recursive --inspect-brk=0.0.0.0 bin/test --android",
33
33
  "test:debugger:ios": "mocha --recursive --inspect-brk=0.0.0.0 bin/test --ios",
34
34
  "tslint": "tslint -c tslint.json test/**/*.ts",
35
- "publish": "npm publish --access=public",
35
+ "release": "bash scripts/release.sh",
36
36
  "eslint": "eslint --quiet .",
37
37
  "jest": "jest versioning/*"
38
38
  },
@@ -0,0 +1,86 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # npm 배포 + GitHub Release 자동화
5
+ # 사용법: npm run release [-- patch|minor|major]
6
+
7
+ VERSION_TYPE="${1:-patch}"
8
+ VALID_TYPES=("patch" "minor" "major")
9
+
10
+ if [[ ! " ${VALID_TYPES[*]} " =~ " ${VERSION_TYPE} " ]]; then
11
+ echo "Error: Invalid version type '${VERSION_TYPE}'. Use: patch, minor, major"
12
+ exit 1
13
+ fi
14
+
15
+ PACKAGE_NAME=$(node -p "require('./package.json').name")
16
+
17
+ # 1. npm 로그인 확인
18
+ if ! npm whoami &>/dev/null; then
19
+ echo "npm 로그인이 필요합니다."
20
+ echo "계정 정보: https://www.notion.so/algocare/npm-1d59954bcc7b8056b6b6c84612592266"
21
+ npm login --scope=@algocare
22
+ fi
23
+
24
+ # 2. clean working tree 확인
25
+ if [[ -n "$(git status --porcelain)" ]]; then
26
+ echo "Error: Working tree is not clean. Commit or stash changes first."
27
+ exit 1
28
+ fi
29
+
30
+ # 3. master 브랜치 확인
31
+ CURRENT_BRANCH=$(git branch --show-current)
32
+ if [[ "${CURRENT_BRANCH}" != "master" ]]; then
33
+ echo "Error: Must be on master branch. Current: ${CURRENT_BRANCH}"
34
+ exit 1
35
+ fi
36
+
37
+ # 4. 버전 bump
38
+ NEW_VERSION=$(npm version "${VERSION_TYPE}" --no-git-tag-version)
39
+ echo "Bumped to ${NEW_VERSION}"
40
+
41
+ # 5. commit + tag
42
+ git add package.json
43
+ git commit -m "${NEW_VERSION}"
44
+ git tag "${NEW_VERSION}"
45
+
46
+ # 6. npm publish (OTP/브라우저 인증 시 에러 코드 반환 후 백그라운드에서 성공할 수 있음)
47
+ echo "Publishing to npm..."
48
+ if ! npm publish --access public --ignore-scripts; then
49
+ echo "npm publish가 에러를 반환했습니다. 실제 배포 여부 확인 중..."
50
+ sleep 3
51
+ PUBLISHED=$(npm view "${PACKAGE_NAME}@${NEW_VERSION}" version 2>/dev/null || true)
52
+ if [[ "${PUBLISHED}" == "${NEW_VERSION#v}" ]]; then
53
+ echo "확인 완료: ${NEW_VERSION} 이미 배포되어 있습니다. 계속 진행합니다."
54
+ else
55
+ echo "Error: npm publish 실패. 수동으로 확인해주세요."
56
+ echo "복구: git tag -d ${NEW_VERSION} && git reset --soft HEAD~1"
57
+ exit 1
58
+ fi
59
+ fi
60
+
61
+ # 7. git push
62
+ if ! git push || ! git push --tags; then
63
+ echo "Error: git push 실패. npm에는 이미 배포됨."
64
+ echo "수동으로 push해주세요: git push && git push --tags"
65
+ exit 1
66
+ fi
67
+
68
+ # 8. GitHub Release (최근 태그 이후 커밋 기반 노트 생성)
69
+ PREV_TAG=$(git tag --sort=-v:refname | sed -n '2p')
70
+ RELEASE_NOTES=""
71
+
72
+ if [[ -n "${PREV_TAG}" ]]; then
73
+ RELEASE_NOTES=$(git log "${PREV_TAG}..${NEW_VERSION}" --merges --pretty=format:"- %s" 2>/dev/null || true)
74
+ fi
75
+
76
+ if [[ -z "${RELEASE_NOTES}" ]]; then
77
+ RELEASE_NOTES=$(git log "${PREV_TAG:-HEAD~5}..${NEW_VERSION}" --no-merges --pretty=format:"- %s" 2>/dev/null | head -20)
78
+ fi
79
+
80
+ if ! gh release create "${NEW_VERSION}" --title "${NEW_VERSION#v}" --notes "${RELEASE_NOTES}"; then
81
+ echo "Warning: GitHub Release 생성 실패. 수동으로 생성해주세요:"
82
+ echo " gh release create ${NEW_VERSION} --title \"${NEW_VERSION#v}\""
83
+ fi
84
+
85
+ echo ""
86
+ echo "Done! ${NEW_VERSION} published and released."
@@ -167,9 +167,11 @@ export interface RemotePackage extends Package {
167
167
  * Downloads the available update from the CodePush service.
168
168
  *
169
169
  * @param downloadProgressCallback An optional callback that allows tracking the progress of the update while it is being downloaded.
170
+ * @param downloadCompleteCallback An optional callback that is called when the HTTP download completes and post-processing begins (iOS only).
170
171
  */
171
172
  download(
172
- downloadProgressCallback?: DownloadProgressCallback
173
+ downloadProgressCallback?: DownloadProgressCallback,
174
+ downloadCompleteCallback?: () => void
173
175
  ): Promise<LocalPackage>
174
176
 
175
177
  /**
@@ -490,6 +492,12 @@ declare namespace CodePush {
490
492
  * An available update was downloaded and is about to be installed.
491
493
  */
492
494
  INSTALLING_UPDATE,
495
+
496
+ /**
497
+ * The update has been downloaded and post-processing (unzip/file copy) is in progress.
498
+ * This status is only reported on iOS when the native module supports it.
499
+ */
500
+ PREPARING_UPDATE,
493
501
  }
494
502
 
495
503
  /**