Skip to content

[Feat] Share Extension을 통한 외부 앱 네컷사진 저장 기능 추가#187

Merged
Remaked-Swain merged 22 commits intodevelopfrom
feat/#182-share-extension
Mar 25, 2026
Merged

[Feat] Share Extension을 통한 외부 앱 네컷사진 저장 기능 추가#187
Remaked-Swain merged 22 commits intodevelopfrom
feat/#182-share-extension

Conversation

@Remaked-Swain
Copy link
Copy Markdown
Member

@Remaked-Swain Remaked-Swain commented Mar 23, 2026

🌴 작업한 브랜치

✅ 작업한 내용

개요

Share Extension을 활용하여 외부 앱(Photos 등)에서 Neki 메인 앱으로 네컷사진을 안전하게 공유하고 S3에 업로드하는 기능을 추가합니다.
이 기능은 Clean Architecture에 기반한 관심사 분리 원칙 등에 의거 작성되었으며, 한 번에 저장할 수 있는 최대 이미지 개수 10장 등 주요 비즈니스 규칙을 동일하게 적용하여 개발되었습니다.
메인 앱의 다양한 생명주기(Terminated, Suspended 등)와 로그인 상태 등 엣지 케이스를 고려하여 DeepLink를 통해 안정적으로 이미지 공유 시퀀스를 제공합니다.

1. 타겟 및 컨테이너 설정

Share Extension 타겟 추가와 App Group Capabilities를 추가하고 애플 디벨로퍼스에 적용한 주요 식별자(Debug, Release 각각)를 똑같이 적용했습니다. 단 공유 확장과 메인 앱은 두 개의 스키마로 분리가 되어있기 때문에 Capabilities 추가에 따른 Entitlements도 분리해주어야 합니다. 다음의 스크린샷처럼 각 타겟에서 스키마별 Entitlements 파일 위치를 알려주고, 실제 Entitlements 파일 각각에 대해 식별자를 작성해주면 됩니다.

스크린샷 2026-03-23 오후 2 57 00 스크린샷 2026-03-23 오후 2 57 31

2. Share Extension 주요 구현부

Share Extension 및 App Group을 사용해 샌드박스를 넘어 외부 앱으로부터 Neki 메인 앱으로 이미지를 공유받기 위해서는 중간 공유저장소가 필요합니다. 그 이유는 단순 문자열이나 가벼운 자료라면 DeepLink 자체에 내용을 싣거나 해도 무방하지만 이미지 자료는 때때로 아주 무거울 수 있기 때문입니다. 따라서 UserDefaults는 무거운 자료를 담기에 부적절하고 CoreData는 과하다고 판단, FileManager를 공유저장소로 채택했습니다.

공유 대상은 오직 '이미지'로 제한되어야 하므로 Share-Extension의 Info.plist를 꼭 설정하여야 합니다!
NSExtensionActivationRule
또한 스토리보드 없이 코드베이스로 UI를 구현했으므로, 스토리보드 관련 내용을 제거하고 ShareViewController를 사용하도록 운영체제에 알려주어야 합니다.
NSExtensionPrincipleClass

이미지 공유 시퀀스는 간단하게 아래와 같이 진행됩니다.

  1. 외부 앱에서 이미지를 공유할 때 해당 이미지의 주소를 확보해 FileManager에 임시 디렉토리를 구성하고 저장합니다.
  2. 완료되면 DeepLink를 호출해 메인 앱으로 라우팅합니다.
  3. 메인 앱에서 DeepLink 수신을 시작으로 액션이 트리거되면 FileManager 기반의 Repository를 통해 FileManager 임시 디렉토리에 기록된 이미지 주소를 확보하고 해당 주소에 담긴 이미지 데이터를 가져옵니다.
  4. 이후 업로드 과정은 기존의 UploadClient 로직을 그대로 재사용합니다.

주요 타입

struct ShareExtensionConfiguration {
    let appGroupID: String
    let deepLinkURL: URL?
    
    init(bundle: Bundle = .main) {
        #if DEBUG
        self.appGroupID = "group.com.Neki-dev.Share-Extension"
        #else
        self.appGroupID = "group.com.OneTen.Neki-iOS.Share-Extension"
        #endif
        
        var components = URLComponents(string: "neki://shareExtension")
        components?.queryItems = [URLQueryItem(name: "appGroupID", value: appGroupID)]
        self.deepLinkURL = components?.url
    }
}

확장 타겟과 메인 타겟은 같은 App Group 식별자를 통해 서로를 인식하고 공유 기능을 이용할 수 있습니다. 따라서 같은 식별자가 서로 다른 타겟에 하드코딩 되었을 경우, 오타 등의 사소한 휴먼에러로 공유 기능 전체에 버그가 발생할 수 있기 때문에 DeepLink에 App Group 식별자를 담아 하드코딩을 방지합니다. (메인 앱은 이 DeepLink에 담긴 식별자를 사용해 FileManager 임시 디렉토리를 구분하도록 구현했기 때문입니다.)

스키마에 따라 식별자가 다르므로 #if DEBUG 전처리 구문을 통해 설정하도록 했습니다.


struct ImageShareProcessor {
    private let repository: ExtensionImageRepository
    
    init(repository: ExtensionImageRepository) { self.repository = repository }
    
    private func processAndSaveImage(from provider: NSItemProvider) async throws {
        let suggestedName = provider.suggestedName
        
        return try await withCheckedThrowingContinuation { continuation in
            provider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in
                if let error = error { return continuation.resume(throwing: ImageShareProcessorError.loadFailed(error)) }
                
                guard let url = url else { return continuation.resume(throwing: ImageShareProcessorError.unknown) }
                let fileName = suggestedName ?? url.lastPathComponent
                
                do {
                    try repository.saveImage(from: url, fileName: fileName)
                    continuation.resume()
                } catch {
                    continuation.resume(throwing: error)
                }
            }
        }
    }
}


// MARK: - ImageShareProcessor + ImageShareUseCase

extension ImageShareProcessor: ImageShareUseCase {
    // 생략...
    
    func share(providers: [NSItemProvider]) async throws {
        guard providers.isEmpty == false else { throw ImageShareProcessorError.noImageFound }
        
        try await withThrowingTaskGroup(of: Void.self) { group in
            for provider in providers {
                group.addTask { try await processAndSaveImage(from: provider) }
            }
            
            for try await _ in group {}
        }
    }
}

공유하려는 이미지의 형식 등 유효성을 간단히 검사하고 원본 주소를 확보한다는 비즈니스 로직을 표현합니다. URL을 어디에, 어떻게 저장하는지는 Repository Layer로 추상화되었습니다.


struct FileManagerExtensionImageRepository: ExtensionImageRepository, @unchecked Sendable {
    let sharedDirectory: URL?
    
    private let fileManager: FileManager
    
    init(appGroupID: String, fileManager: FileManager = .default) {
        self.fileManager = fileManager
        let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)
        self.sharedDirectory = groupURL?.appendingPathComponent("SharedImages", conformingTo: .directory)
    }
    
    func saveImage(from sourceURL: URL, fileName: String) throws {
        guard let directory = sharedDirectory else { throw ExtensionImageRepositoryError.directoryNotFound }
        let destinationURL = directory.appendingPathComponent(fileName, conformingTo: .image)
        try ensureDirectoryExists(at: directory)
        try removeExistingFileIfNeeded(at: destinationURL)
        try copyFile(from: sourceURL, to: destinationURL)
    }
}

private extension FileManagerExtensionImageRepository {
    // 생략...
}

FileManager 기반의 Repository 실구현체입니다. 저장은 임시 디렉토리 파고(ensureDirectoryExists) 중복파일이 있다면 정리한다거나하는 전처리(removeExistingFileIfNeeded) 과정을 거쳐 원본 이미지 URL을 기록(copyFile)하는 순서로 진행됩니다다.


이미지 원본 자체(Data)를 복사하는 것은 비효율적이며 메모리 스파이크로 인한 크래시를 유발할 수 있으므로, URL을 저장하는 방식을 택했습니다. 왜냐하면 이미지 축소, 병렬처리 등의 안전장치는 메인 앱에서 이미 제공하고 있기 떄문에 공유 시트 자체는 URL만을 다루고 실제로 메모리가 우려되는 부분은 메인 앱에서 이미 검증된 다운샘플링 로직에 해결을 기대하는 방식입니다.

메인 앱 라우팅 및 사용자 경험 처리

SLComposeServiceViewController를 상속받아 공유 시트를 구현할 경우, 개발 리소스는 가장 적을 수 있으나 불필요한 UI 요소(텍스트필드 등)가 있어 UIViewController를 상속해 직접 커스터마이징 하였습니다. 오랜만에 오토레이아웃을 잡으면서 왜 제가 SwiftUI를 주력기로 삼게 되었는지 다시 느낄 수 있던 시간이었습니다.

func openMainApp(with url: URL) {
    var responder: UIResponder? = self
    while responder != nil {
        if let application = responder as? UIApplication {
            application.open(url)
            break
        }
        responder = responder?.next
    }
    self.extensionContext?.completeRequest(returningItems: [])
}

애플 공식문서에 의하면 위젯이 아닌 경우, 딥링크를 호출하기 위한 메서드를 사용할 수 없습니다.(context.open(url:)) 따라서 우리는 편법을 사용해야 합니다. UIViewController를 상속받아 구현된 ShareViewController는 결국 리스폰더 체인의 일부라는 점을 이용, 리스폰더 체인을 위로 거슬러 올라가며 UIApplication 객체를 찾아낸 뒤 해당 객체에 DeepLink를 열도록 하여 메인 앱으로 라우팅 시킬 수 있습니다.

❗️PR Point

  1. ShareViewController의 커스터마이징 디자인은 제 임의로 진행했습니다. 디자인팀에서 별도의 디자인 계획이 없다고 했기 때문입니다. 테스트플라이트 등으로 보여준 뒤 QA 따위를 통해 리디자인 가능성 열어두고 있습니다.
  2. 이미지 공유 과정 중, 로그인 전이라면 로그인을 수행한 뒤에 공유 과정을 이어서 진행하도록 했습니다. 이러한 엣지케이스들을 더 발견하고 대응하여 안정성을 높여야 합니다. 놓친 부분이 있다거나 추천할만한 아이디어가 있다면 말씀해주세요!
  3. pbxproj merge conflict를 잡다가 실수하여 아래와 같은 기현상이 발생했습니다. 파일 레퍼런스 정리가 필요할 것 같습니다.
스크린샷 2026-03-23 오후 7 35 21

📸 스크린샷

톡방에 올린 시연자료로 대체합니다!

📟 관련 이슈

Summary by CodeRabbit

  • New Features

    • iOS 공유 확장 추가: 앱 외부에서 이미지 선택·미리보기·저장·전송(최대 10장) 및 공유 후 앱 자동 연동.
    • 공유 이미지 저장/읽기용 클라이언트·리포지토리·유스케이스와 확장 내 이미지 처리 흐름 추가.
    • 딥링크(neki://shareExtension)로 확장 트리거를 받아 앱에서 자동 업로드·앨범 추가 진행.
    • 다중 이미지 동시 업로드(배치 처리) 지원.
  • Chores

    • 앱 버전 1.2.0 업데이트 및 디버그/릴리스용 entitlements·앱 그룹·연관 도메인 설정 추가.

@Remaked-Swain Remaked-Swain added this to the 3차 스프린트 milestone Mar 23, 2026
@Remaked-Swain Remaked-Swain requested a review from OneTen19 March 23, 2026 10:47
@Remaked-Swain Remaked-Swain self-assigned this Mar 23, 2026
@Remaked-Swain Remaked-Swain added Add ✚ 코드, 파일, 에셋 추가 Chore 🪡 자잘한 코드 수정 Feat 💻 기능 구현 Style 🖼️ UI 작업 금용 🐲 금용 작업 labels Mar 23, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 23, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a2243066-c1ee-42cc-92cc-e8ef80d61256

📥 Commits

Reviewing files that changed from the base of the PR and between a5e27de and e8c9dc0.

📒 Files selected for processing (1)
  • Neki-iOS/Features/Archive/Sources/Domain/Sources/Client/SharedImageClient.swift

Walkthrough

앱 그룹 기반 Share Extension 타겟과 관련 리포지토리·유틸리티·UI·엔타이틀먼트가 추가되고, 딥링크로 메인 앱을 트리거해 공유 이미지들을 병렬 업로드하고 정리하는 흐름이 구현되었습니다.

Changes

Cohort / File(s) Summary
프로젝트 설정
Neki-iOS.xcodeproj/project.pbxproj
NekiShareExtension 네이티브 타겟·제품(.appex) 및 임베드/타겟 의존성 추가; 빌드 설정(엔타이틀먼트 경로, 버전 등) 변경; 테스트 프레임워크 참조 제거
앱 진입점 · 라우팅
Neki-iOS/APP/Sources/Application/Neki_iOSApp.swift, Neki-iOS/APP/Sources/Application/AppCoordinator.swift
neki:// 딥링크를 상태 관리로 전달하도록 변경; pendingShareAppGroupID 상태와 지연 실행(onOpenURL, executePendingShareExtensionIfNeeded) 로직 추가
공유 이미지 저장소 · 클라이언트
Neki-iOS/Features/Archive/.../SharedImageRepository.swift, .../FileManagerSharedImageRepository.swift, .../SharedImageClient.swift
SharedImageRepository 프로토콜 도입 및 App Group의 SharedImages 디렉터리에서 파일 조회·삭제하는 FileManagerSharedImageRepository 구현과 의존성 등록
이미지 업로드 클라이언트
Neki-iOS/Core/Sources/ImagePicker/Domain/ImageUploadClient.swift
uploadConcurrentlyFromURLs 추가: 파일 데이터 기반 엔티티 생성 후 고정 배치(3) 단위로 병렬 업로드하고 미디어 ID 목록 반환
아카이브 피처 통합
Neki-iOS/Features/Archive/.../ArchiveFeature.swift
액션·의존성 추가(addPhotoFromShareExtension, cleanSharedImages, uploadSharedImagesResponse, imageUploadClient, sharedImageClient): 공유 이미지 조회 → 병렬 업로드 → 정리 → 결과 처리 흐름 구현
엔타이틀먼트 · Info.plist
Neki-iOS/Neki-iOS-Debug.entitlements, Neki-iOS/Neki-iOS-Release.entitlements, NekiShareExtension/*-Debug.entitlements, NekiShareExtension/*-Release.entitlements, NekiShareExtension/Resources/Info.plist
Debug/Release용 App Group 엔타이틀먼트 추가(디버그/릴리즈 그룹 식별자 분리) 및 확장용 Info.plist에 이미지 전용 활성화 규칙(최대 10개) 추가
Extension 저장소·인터페이스
NekiShareExtension/Sources/Domain/.../ExtensionImageRepository.swift, .../FileManagerExtensionImageRepository.swift
Extension 측 저장소 프로토콜·에러 정의와 App Group 경로 기반 파일 저장 구현(saveImage(from:fileName:)) 추가
Extension 구성·유틸리티
NekiShareExtension/Sources/Domain/.../ShareExtensionConfiguration.swift, .../ImageShareProcessor.swift
앱그룹 ID·딥링크 구성 구조체 추가; ImageShareProcessor로 NSItemProvider에서 이미지 추출·미리보기 로드·파일 저장을 비동기(continuation·TaskGroup)로 처리
Share Extension UI
NekiShareExtension/Sources/Presentation/Sources/ShareViewController.swift
포퍼 UI(미리보기·카운트 배지·취소/게시) 추가: 입력 아이템 검사(최대 10개), 미리보기 표시, 게시 시 저장 처리 후 딥링크로 메인 앱 오픈 및 extensionContext 완료/취소 처리

시퀀스 다이어그램

sequenceDiagram
    participant User as 사용자(소스 앱)
    participant ShareExt as Share Extension
    participant Container as App Group 컨테이너
    participant MainApp as 메인 앱
    participant Archive as Archive 기능

    User->>ShareExt: 이미지 공유 요청
    activate ShareExt
    ShareExt->>ShareExt: NSItemProvider에서 이미지 추출/검증
    ShareExt->>Container: 파일 저장 (SharedImages)
    Container-->>ShareExt: 저장 완료
    ShareExt->>MainApp: Deep Link 호출 (neki://shareExtension?appGroupID=...)
    deactivate ShareExt

    activate MainApp
    MainApp->>MainApp: onOpenURL 수신 → pendingShareAppGroupID 설정
    MainApp->>Archive: addPhotoFromShareExtension(appGroupID)
    activate Archive
    Archive->>Container: shared image URLs 조회
    Container-->>Archive: [URL,...]
    Archive->>Archive: uploadConcurrentlyFromURLs (병렬 업로드, batch size 3)
    Archive->>Container: 업로드 후 shared images 정리
    Container-->>Archive: 정리 완료
    Archive-->>MainApp: uploadSharedImagesResponse (성공/실패)
    deactivate Archive
    MainApp->>MainApp: 사용자에게 결과 표시
    deactivate MainApp
Loading

예상 코드 리뷰 노력

🎯 4 (Complex) | ⏱️ ~50 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed 제목은 Share Extension을 통한 외부 앱 네컷사진 저장 기능 추가라는 PR의 핵심 변경사항을 명확하고 간결하게 요약합니다.
Description check ✅ Passed PR 설명이 작업한 브랜치, 작업 내용(개요, 타겟 설정, 구현부 상세 설명), PR Point, 관련 이슈를 포함하며 템플릿의 대부분 필수 섹션을 충족합니다.
Linked Issues check ✅ Passed 코드 변경사항이 #182의 모든 핵심 요구사항을 충족합니다: Share Extension 타겟 추가, NSExtensionActivationRule 설정, App Groups 설정, 메모리 최적화된 이미지 처리, Deep Link를 통한 라우팅, 임시 파일 정리 로직.
Out of Scope Changes check ✅ Passed 모든 변경사항이 #182의 Share Extension 기능 구현 범위 내에 있으며, 기존 UploadClient 재사용 외 불필요한 변경은 없습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/#182-share-extension

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Neki-iOS/APP/Sources/Application/AppCoordinator.swift (1)

196-203: ⚠️ Potential issue | 🟠 Major

.userSessionStatusChanged(.signedIn) 경로에서 pending share extension 실행이 누락되었습니다.

.userSessionStatusChanged 액션으로 사용자가 signedIn 상태로 전환될 때, 라우트가 .mainTab으로 설정되지만 executePendingShareExtensionIfNeeded가 호출되지 않습니다. 같은 signedIn 상태로의 라우팅을 처리하는 다른 경로들(.route(.auth(.delegate(.moveToMainTab))) 226번 줄, navigateToNextScreen 함수 259번 줄)에서는 모두 이 함수를 명시적으로 호출하고 있어, 일관성이 필요합니다.

공유 확장 기능이 사용자 세션 변경 시나리오에서 실행되지 않을 수 있으므로, 이 경로에서도 .send(.executePendingShareExtensionIfNeeded)를 반환하도록 수정하세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Neki-iOS/APP/Sources/Application/AppCoordinator.swift` around lines 196 -
203, In the .userSessionStatusChanged(.signedIn) branch, after setting
state.route = .mainTab(.init(user: user)) ensure you also return a
.send(.executePendingShareExtensionIfNeeded) so the pending share extension
runs; locate the switch handling .userSessionStatusChanged(.signedIn) in
AppCoordinator (the case let .signedIn(user) block that mutates state.route) and
change the return from .none to .send(.executePendingShareExtensionIfNeeded)
(matching other paths like .route(.auth(.delegate(.moveToMainTab))) and
navigateToNextScreen).
🧹 Nitpick comments (11)
Neki-iOS/APP/Sources/Application/Neki_iOSApp.swift (1)

25-28: 커스텀 스킴은 최소 화이트리스트 검증 후 디스패치하는 것을 권장합니다.

Line 25에서 scheme만 확인하고 전달하면 의도치 않은 neki://... 입력도 동일 경로로 들어옵니다. host == "shareExtension"appGroupID 존재를 먼저 확인하면 노이즈 액션/예외 경로를 줄일 수 있습니다.

예시 수정안
     private func handleIncomingURL(_ url: URL) {
-        if url.scheme == "neki" {
+        if url.scheme?.lowercased() == "neki" {
+            let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
+            let hasAppGroupID = components?.queryItems?.contains {
+                $0.name == "appGroupID" && !($0.value ?? "").isEmpty
+            } == true
+            guard url.host == "shareExtension", hasAppGroupID else { return }
+
             store.send(.onOpenURL(url))
             return
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Neki-iOS/APP/Sources/Application/Neki_iOSApp.swift` around lines 25 - 28,
Validate the incoming URL more strictly before dispatching: instead of only
checking url.scheme == "neki" in Neki_iOSApp.swift, also verify url.host ==
"shareExtension" and that the required appGroupID exists (e.g., check
AppConfig.appGroupID or similar) before calling store.send(.onOpenURL(url)); if
any check fails, do not dispatch and optionally handle/log the unexpected URL.
This change targets the url handling branch where store.send(.onOpenURL(url)) is
invoked to reduce noise and unintended activations.
Neki-iOS/Features/Archive/Sources/Domain/Sources/Interfaces/Repositories/SharedImageRepository.swift (1)

10-14: appGroupID를 값 객체로 감싸는 리팩터를 권장합니다.

현재처럼 String을 직접 받으면 호출부 오타가 런타임 실패로만 드러납니다. 타입을 분리하면 API 안정성이 올라갑니다.

예시 수정안
 import Foundation

+public struct AppGroupID: RawRepresentable, Hashable, Sendable {
+    public let rawValue: String
+    public init(rawValue: String) { self.rawValue = rawValue }
+}
+
 public protocol SharedImageRepository {
-    func fetchSharedImages(appGroupID: String) async throws -> [ImageUploadEntity]
-    func fetchSharedImageURLs(appGroupID: String) async throws -> [URL]
-    func clearSharedImages(appGroupID: String) async throws
+    func fetchSharedImages(appGroupID: AppGroupID) async throws -> [ImageUploadEntity]
+    func fetchSharedImageURLs(appGroupID: AppGroupID) async throws -> [URL]
+    func clearSharedImages(appGroupID: AppGroupID) async throws
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Domain/Sources/Interfaces/Repositories/SharedImageRepository.swift`
around lines 10 - 14, 리포지토리 메서드들이 모든 곳에서 문자열 오타로 인한 런타임 버그에 취약하므로 appGroupID
파라미터를 String에서 값 객체로 교체하세요: 새 Value Object 타입(예: AppGroupID) 를 정의하고
SharedImageRepository의 메서드 시그니처(fetchSharedImages, fetchSharedImageURLs,
clearSharedImages)의 appGroupID 타입을 해당 값 객체로 변경한 뒤, 모든 호출부를 찾아 문자열 대신 AppGroupID
인스턴스를 생성하여 전달하도록 수정하고, 필요하면 값 객체에 유효성 검사/표현자(예: rawValue 또는 urlEncoded())를 추가해
사용성을 보장하세요.
Neki-iOS/Features/Archive/Sources/Data/Sources/FileManagerSharedImageRepository.swift (1)

14-26: Sendable 적합성 확인이 필요합니다.

FileManagerSharedImageRepositoryasync 메서드를 가지고 있고 DependencyKey로 등록되어 concurrent context에서 사용되지만, Sendable 적합성이 선언되지 않았습니다.

FileManager.default는 thread-safe하므로 @unchecked Sendable을 추가하는 것이 좋습니다 (Share Extension의 FileManagerExtensionImageRepository와 동일하게).

♻️ Sendable 적합성 추가
-public struct FileManagerSharedImageRepository {
+public struct FileManagerSharedImageRepository: `@unchecked` Sendable {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Data/Sources/FileManagerSharedImageRepository.swift`
around lines 14 - 26, The struct FileManagerSharedImageRepository lacks Sendable
conformance but is used concurrently; update its definition to declare Sendable
conformance (use `@unchecked` Sendable) and ensure the private property
fileManager (initialized with FileManager.default in init) is the reason for
unchecked conformance; match the approach used in
FileManagerExtensionImageRepository so the type can be used safely in concurrent
contexts.
NekiShareExtension/Sources/Domain/Sources/Entities/ShareExtensionConfiguration.swift (2)

10-12: ShareExtensionConfigurationError가 정의되었지만 사용되지 않습니다.

이 에러 enum이 선언되었으나 현재 코드에서 throw되는 곳이 없습니다. 향후 사용 예정이라면 TODO 주석을 추가하거나, 불필요하다면 제거를 고려하세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@NekiShareExtension/Sources/Domain/Sources/Entities/ShareExtensionConfiguration.swift`
around lines 10 - 12, The enum ShareExtensionConfigurationError is declared but
never used; either remove the unused enum to eliminate dead code or keep it and
add a clear TODO comment explaining intended future use and where it will be
thrown (e.g., during validation in ShareExtensionConfiguration initializers or
parsing methods). Locate the symbol ShareExtensionConfigurationError and either
delete its declaration or annotate it with a TODO explaining the planned error
cases and where to implement throws (for example in configuration
parsing/validation functions like init(from:) or validateConfiguration()).

18-28: 초기화 파라미터 bundle이 사용되지 않습니다.

init(bundle: Bundle = .main) 파라미터가 선언되었으나 실제로 사용되지 않습니다. 향후 확장성을 위해 남겨둔 것이라면 _ bundle 또는 주석으로 의도를 명시하세요.

App Group ID 설정은 entitlements 파일과 정확히 일치합니다. ✓

♻️ 미사용 파라미터 제거 제안
-    init(bundle: Bundle = .main) {
+    init() {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@NekiShareExtension/Sources/Domain/Sources/Entities/ShareExtensionConfiguration.swift`
around lines 18 - 28, The init(bundle: Bundle = .main) parameter in
ShareExtensionConfiguration.init(bundle:) is declared but never used; either
remove the unused parameter or mark it as intentionally unused by renaming it to
_bundle (or prefix with _ as _ bundle) and add a short comment explaining it's
reserved for future extension; update the initializer signature and ensure
references to appGroupID and deepLinkURL remain unchanged.
Neki-iOS/Core/Sources/ImagePicker/Domain/ImageUploadClient.swift (1)

27-54: 업로드된 미디어 ID 순서가 입력 URL 순서와 다를 수 있습니다.

withThrowingTaskGroupfor try await 루프는 완료 순서대로 결과를 수집하므로, 반환되는 uploadedMediaIDs 배열의 순서가 입력 fileURLs 순서와 일치하지 않을 수 있습니다.

현재 사용처(ArchiveFeature)에서 순서가 중요하지 않다면 문제없지만, 순서가 필요하다면 인덱스와 함께 결과를 수집해야 합니다.

♻️ 순서 보장이 필요한 경우 제안
 uploadConcurrentlyFromURLs: { fileURLs, mediaType in
-    return try await withThrowingTaskGroup(of: [Int].self) { group in
-        var uploadedMediaIDs: [Int] = []
+    return try await withThrowingTaskGroup(of: (Int, [Int]).self) { group in
+        var uploadedMediaIDs: [(Int, [Int])] = []
         let maxConcurrentTasks = 3
-        var urlIterator = fileURLs.makeIterator()
+        var urlIterator = fileURLs.enumerated().makeIterator()
         
         for _ in 0..<maxConcurrentTasks {
-            guard let url = urlIterator.next() else { break }
+            guard let (index, url) = urlIterator.next() else { break }
             group.addTask {
                 let data = try Data(contentsOf: url)
                 let entity = ImageUploadEntity(data: data, format: data.detectedImageFormat)
-                return try await repository.upload(items: [entity], mediaType: mediaType)
+                return (index, try await repository.upload(items: [entity], mediaType: mediaType))
             }
         }
         
-        for try await resultIDs in group {
-            uploadedMediaIDs.append(contentsOf: resultIDs)
+        for try await result in group {
+            uploadedMediaIDs.append(result)
             
-            guard let nextURL = urlIterator.next() else { continue }
+            guard let (nextIndex, nextURL) = urlIterator.next() else { continue }
             group.addTask {
                 let data = try Data(contentsOf: nextURL)
                 let entity = ImageUploadEntity(data: data, format: data.detectedImageFormat)
-                return try await repository.upload(items: [entity], mediaType: mediaType)
+                return (nextIndex, try await repository.upload(items: [entity], mediaType: mediaType))
             }
         }
         
-        return uploadedMediaIDs
+        return uploadedMediaIDs.sorted(by: { $0.0 < $1.0 }).flatMap { $0.1 }
     }
 },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Neki-iOS/Core/Sources/ImagePicker/Domain/ImageUploadClient.swift` around
lines 27 - 54, The task group currently gathers results in completion order so
uploadedMediaIDs may not match the input fileURLs order; modify
uploadConcurrentlyFromURLs to preserve order by capturing each URL's index when
you add a task (use the iterator index or enumerate fileURLs), have each group
task return a tuple like (index: Int, ids: [Int]) from repository.upload,
collect all tuples from the withThrowingTaskGroup, sort them by index, then
flatten the ids into uploadedMediaIDs in the original fileURLs order before
returning; update any references to uploadedMediaIDs handling accordingly
(functions: uploadConcurrentlyFromURLs, ImageUploadEntity creation,
repository.upload).
Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveFeature.swift (1)

296-299: 빈 이미지 목록 시 토스트가 두 번 표시될 수 있습니다.

Line 297에서 showToast를 직접 호출하고, Line 298에서 uploadSharedImagesResponse(.failure(...))를 보내면 Line 367-369에서 다시 에러 토스트가 표시됩니다.

하나의 에러 경로만 사용하는 것이 좋습니다.

♻️ 중복 토스트 제거 제안
 guard fileURLs.isEmpty == false else {
-    await send(.delegate(.showToast(NekiToastItem("가져올 수 있는 이미지가 없어요.", style: .error))))
     await send(.uploadSharedImagesResponse(.failure(UploadError.uploadFailed)))
     return
 }

그리고 uploadSharedImagesResponse(.failure) 핸들러에서 에러 타입에 따라 적절한 메시지를 표시하도록 수정하거나, 현재처럼 일반 메시지를 사용합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveFeature.swift`
around lines 296 - 299, The guard in ArchiveFeature (the block that calls
send(.delegate(.showToast(...))) then
send(.uploadSharedImagesResponse(.failure(...)))) causes duplicate toasts
because the uploadSharedImagesResponse failure is also handled later to show a
toast; change this to a single error path by removing the direct
send(.delegate(.showToast(...))) in that guard and only send the failure via
send(.uploadSharedImagesResponse(.failure(...))), or alternatively update the
uploadSharedImagesResponse failure handler to accept and show the message while
removing the inline toast; locate the guard around fileURLs and the
uploadSharedImagesResponse handler in ArchiveFeature and apply one of these two
fixes so only one toast is produced.
NekiShareExtension/Sources/Presentation/Sources/ShareViewController.swift (1)

166-170: 이미지 초과 시 badge 색상 가독성이 낮을 수 있습니다.

Line 97에서 backgroundColor.systemBlue로 설정되고, 초과 시 textColor.systemRed로 변경됩니다. 파란 배경에 빨간 텍스트는 가독성이 낮을 수 있습니다.

♻️ 초과 시 배경색도 함께 변경
 let isOverLimit = count > maxImageCount
 countBadgeLabel.text = isOverLimit ? "10+" : "\(count)"
-countBadgeLabel.textColor = isOverLimit ? .systemRed.withAlphaComponent(0.9) : .label
+countBadgeLabel.textColor = isOverLimit ? .white : .label
+countBadgeLabel.backgroundColor = isOverLimit ? .systemRed.withAlphaComponent(0.9) : .systemBlue.withAlphaComponent(0.9)
 countBadgeLabel.isHidden = count < 2
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@NekiShareExtension/Sources/Presentation/Sources/ShareViewController.swift`
around lines 166 - 170, The badge becomes hard to read when countBadgeLabel has
a .systemBlue background but only its textColor is switched to .systemRed when
over the limit; update the badge styling so that when isOverLimit you change
countBadgeLabel.backgroundColor to a high-contrast color (e.g.,
.systemRed.withAlphaComponent(0.15) or a darker/red-tinted color) and adjust
countBadgeLabel.textColor to a readable foreground (e.g., .systemRed or .white)
and when not over limit restore the original background (.systemBlue) and
textColor (.label); modify the code around countBadgeLabel (where
backgroundColor is set) so both backgroundColor and textColor are toggled
together when computing isOverLimit, keeping
navigationBar.items?.first?.rightBarButtonItem?.isEnabled logic unchanged.
NekiShareExtension/Sources/Domain/Sources/Utilities/ImageShareProcessor.swift (2)

11-16: invalidProvider 에러 케이스가 사용되지 않습니다.

ImageShareProcessorError.invalidProvider가 선언되었지만 현재 구현에서 사용되지 않습니다. 의도적으로 향후 사용을 위해 예약한 것이라면 주석을 추가하거나, 그렇지 않다면 제거를 고려해 주세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@NekiShareExtension/Sources/Domain/Sources/Utilities/ImageShareProcessor.swift`
around lines 11 - 16, The ImageShareProcessorError enum declares a case
invalidProvider that is never used; either remove the unused case from
ImageShareProcessorError or, if it's intentionally reserved for future logic,
add a clear comment above invalidProvider in the enum stating it’s reserved for
future use (e.g., "reserved for unsupported provider errors") so reviewers know
it’s intentional; update any related tests or code paths that might expect
invalidProvider if you choose removal.

68-78: Share Extension 메모리 제약 고려.

withThrowingTaskGroup으로 모든 이미지를 동시에 처리합니다. 파일 복사 방식이므로 메모리 사용량은 낮겠지만, Share Extension의 메모리 제한(~120MB)을 고려하여 대용량 이미지 10장 동시 처리 시 안정성을 테스트해 주세요.

필요 시 순차 처리로 변경하거나 동시 실행 수를 제한할 수 있습니다:

♻️ 동시 실행 수 제한 옵션
 func share(providers: [NSItemProvider]) async throws {
     guard providers.isEmpty == false else { throw ImageShareProcessorError.noImageFound }
     
-    try await withThrowingTaskGroup(of: Void.self) { group in
-        for provider in providers {
-            group.addTask { try await processAndSaveImage(from: provider) }
-        }
-        
-        for try await _ in group {}
+    // 순차 처리 방식
+    for provider in providers {
+        try await processAndSaveImage(from: provider)
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@NekiShareExtension/Sources/Domain/Sources/Utilities/ImageShareProcessor.swift`
around lines 68 - 78, The current share(providers:) spawns a task for every
NSItemProvider concurrently which can blow the Share Extension memory; change to
either sequential processing or bounded concurrency: replace the
withThrowingTaskGroup-all-at-once approach and instead call
processAndSaveImage(from:) in a simple for-await loop to process one-by-one, or
implement batching/limited parallelism (e.g., process providers in chunks of
size N or use a counting semaphore/limited TaskGroup) so at most N concurrent
tasks run; update the implementation inside share(providers:) and keep
processAndSaveImage(from:) unchanged, exposing a configurable concurrency limit
constant if needed.
Neki-iOS.xcodeproj/project.pbxproj (1)

355-427: Debug와 Release 간 SWIFT_APPROACHABLE_CONCURRENCY 설정 불일치.

Debug 설정(Line 386)에는 SWIFT_APPROACHABLE_CONCURRENCY = YES가 있지만 Release 설정(Lines 394-427)에는 없습니다. 이로 인해 Debug와 Release 빌드 간 동시성 동작이 달라질 수 있습니다.

일관성을 위해 Release 설정에도 동일한 값을 추가하는 것을 권장합니다.

♻️ Release 설정에 추가
 59235C5C2F64245A00198051 /* Release */ = {
     isa = XCBuildConfiguration;
     buildSettings = {
         ...
         SWIFT_EMIT_LOC_STRINGS = NO;
+        SWIFT_APPROACHABLE_CONCURRENCY = YES;
         SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
         ...
     };
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Neki-iOS.xcodeproj/project.pbxproj` around lines 355 - 427, The Release build
configuration (the XCBuildConfiguration block with name = Release / id
59235C5C2F64245A00198051) is missing the SWIFT_APPROACHABLE_CONCURRENCY build
setting present in Debug; add SWIFT_APPROACHABLE_CONCURRENCY = YES inside the
Release buildSettings to match Debug so concurrency behavior is consistent
across builds.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@NekiShareExtension/Sources/Presentation/Sources/ShareViewController.swift`:
- Around line 189-198: The openMainApp(with:) implementation uses the responder
chain to find UIApplication (which is unsupported in extensions) so replace that
logic by calling NSExtensionContext's open(_:completionHandler:) on
self.extensionContext to open the URL; use the completion handler to call
self.extensionContext?.completeRequest(returningItems: []) (or handle failures
accordingly) and remove the responder-chain loop — update the function named
openMainApp(with:) to use extensionContext.open instead of searching for
UIApplication.

---

Outside diff comments:
In `@Neki-iOS/APP/Sources/Application/AppCoordinator.swift`:
- Around line 196-203: In the .userSessionStatusChanged(.signedIn) branch, after
setting state.route = .mainTab(.init(user: user)) ensure you also return a
.send(.executePendingShareExtensionIfNeeded) so the pending share extension
runs; locate the switch handling .userSessionStatusChanged(.signedIn) in
AppCoordinator (the case let .signedIn(user) block that mutates state.route) and
change the return from .none to .send(.executePendingShareExtensionIfNeeded)
(matching other paths like .route(.auth(.delegate(.moveToMainTab))) and
navigateToNextScreen).

---

Nitpick comments:
In `@Neki-iOS.xcodeproj/project.pbxproj`:
- Around line 355-427: The Release build configuration (the XCBuildConfiguration
block with name = Release / id 59235C5C2F64245A00198051) is missing the
SWIFT_APPROACHABLE_CONCURRENCY build setting present in Debug; add
SWIFT_APPROACHABLE_CONCURRENCY = YES inside the Release buildSettings to match
Debug so concurrency behavior is consistent across builds.

In `@Neki-iOS/APP/Sources/Application/Neki_iOSApp.swift`:
- Around line 25-28: Validate the incoming URL more strictly before dispatching:
instead of only checking url.scheme == "neki" in Neki_iOSApp.swift, also verify
url.host == "shareExtension" and that the required appGroupID exists (e.g.,
check AppConfig.appGroupID or similar) before calling
store.send(.onOpenURL(url)); if any check fails, do not dispatch and optionally
handle/log the unexpected URL. This change targets the url handling branch where
store.send(.onOpenURL(url)) is invoked to reduce noise and unintended
activations.

In `@Neki-iOS/Core/Sources/ImagePicker/Domain/ImageUploadClient.swift`:
- Around line 27-54: The task group currently gathers results in completion
order so uploadedMediaIDs may not match the input fileURLs order; modify
uploadConcurrentlyFromURLs to preserve order by capturing each URL's index when
you add a task (use the iterator index or enumerate fileURLs), have each group
task return a tuple like (index: Int, ids: [Int]) from repository.upload,
collect all tuples from the withThrowingTaskGroup, sort them by index, then
flatten the ids into uploadedMediaIDs in the original fileURLs order before
returning; update any references to uploadedMediaIDs handling accordingly
(functions: uploadConcurrentlyFromURLs, ImageUploadEntity creation,
repository.upload).

In
`@Neki-iOS/Features/Archive/Sources/Data/Sources/FileManagerSharedImageRepository.swift`:
- Around line 14-26: The struct FileManagerSharedImageRepository lacks Sendable
conformance but is used concurrently; update its definition to declare Sendable
conformance (use `@unchecked` Sendable) and ensure the private property
fileManager (initialized with FileManager.default in init) is the reason for
unchecked conformance; match the approach used in
FileManagerExtensionImageRepository so the type can be used safely in concurrent
contexts.

In
`@Neki-iOS/Features/Archive/Sources/Domain/Sources/Interfaces/Repositories/SharedImageRepository.swift`:
- Around line 10-14: 리포지토리 메서드들이 모든 곳에서 문자열 오타로 인한 런타임 버그에 취약하므로 appGroupID
파라미터를 String에서 값 객체로 교체하세요: 새 Value Object 타입(예: AppGroupID) 를 정의하고
SharedImageRepository의 메서드 시그니처(fetchSharedImages, fetchSharedImageURLs,
clearSharedImages)의 appGroupID 타입을 해당 값 객체로 변경한 뒤, 모든 호출부를 찾아 문자열 대신 AppGroupID
인스턴스를 생성하여 전달하도록 수정하고, 필요하면 값 객체에 유효성 검사/표현자(예: rawValue 또는 urlEncoded())를 추가해
사용성을 보장하세요.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveFeature.swift`:
- Around line 296-299: The guard in ArchiveFeature (the block that calls
send(.delegate(.showToast(...))) then
send(.uploadSharedImagesResponse(.failure(...)))) causes duplicate toasts
because the uploadSharedImagesResponse failure is also handled later to show a
toast; change this to a single error path by removing the direct
send(.delegate(.showToast(...))) in that guard and only send the failure via
send(.uploadSharedImagesResponse(.failure(...))), or alternatively update the
uploadSharedImagesResponse failure handler to accept and show the message while
removing the inline toast; locate the guard around fileURLs and the
uploadSharedImagesResponse handler in ArchiveFeature and apply one of these two
fixes so only one toast is produced.

In
`@NekiShareExtension/Sources/Domain/Sources/Entities/ShareExtensionConfiguration.swift`:
- Around line 10-12: The enum ShareExtensionConfigurationError is declared but
never used; either remove the unused enum to eliminate dead code or keep it and
add a clear TODO comment explaining intended future use and where it will be
thrown (e.g., during validation in ShareExtensionConfiguration initializers or
parsing methods). Locate the symbol ShareExtensionConfigurationError and either
delete its declaration or annotate it with a TODO explaining the planned error
cases and where to implement throws (for example in configuration
parsing/validation functions like init(from:) or validateConfiguration()).
- Around line 18-28: The init(bundle: Bundle = .main) parameter in
ShareExtensionConfiguration.init(bundle:) is declared but never used; either
remove the unused parameter or mark it as intentionally unused by renaming it to
_bundle (or prefix with _ as _ bundle) and add a short comment explaining it's
reserved for future extension; update the initializer signature and ensure
references to appGroupID and deepLinkURL remain unchanged.

In
`@NekiShareExtension/Sources/Domain/Sources/Utilities/ImageShareProcessor.swift`:
- Around line 11-16: The ImageShareProcessorError enum declares a case
invalidProvider that is never used; either remove the unused case from
ImageShareProcessorError or, if it's intentionally reserved for future logic,
add a clear comment above invalidProvider in the enum stating it’s reserved for
future use (e.g., "reserved for unsupported provider errors") so reviewers know
it’s intentional; update any related tests or code paths that might expect
invalidProvider if you choose removal.
- Around line 68-78: The current share(providers:) spawns a task for every
NSItemProvider concurrently which can blow the Share Extension memory; change to
either sequential processing or bounded concurrency: replace the
withThrowingTaskGroup-all-at-once approach and instead call
processAndSaveImage(from:) in a simple for-await loop to process one-by-one, or
implement batching/limited parallelism (e.g., process providers in chunks of
size N or use a counting semaphore/limited TaskGroup) so at most N concurrent
tasks run; update the implementation inside share(providers:) and keep
processAndSaveImage(from:) unchanged, exposing a configurable concurrency limit
constant if needed.

In `@NekiShareExtension/Sources/Presentation/Sources/ShareViewController.swift`:
- Around line 166-170: The badge becomes hard to read when countBadgeLabel has a
.systemBlue background but only its textColor is switched to .systemRed when
over the limit; update the badge styling so that when isOverLimit you change
countBadgeLabel.backgroundColor to a high-contrast color (e.g.,
.systemRed.withAlphaComponent(0.15) or a darker/red-tinted color) and adjust
countBadgeLabel.textColor to a readable foreground (e.g., .systemRed or .white)
and when not over limit restore the original background (.systemBlue) and
textColor (.label); modify the code around countBadgeLabel (where
backgroundColor is set) so both backgroundColor and textColor are toggled
together when computing isOverLimit, keeping
navigationBar.items?.first?.rightBarButtonItem?.isEnabled logic unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 330536e7-b941-4a69-90e0-b6aed59b4bca

📥 Commits

Reviewing files that changed from the base of the PR and between fca0540 and f4eda95.

📒 Files selected for processing (18)
  • Neki-iOS.xcodeproj/project.pbxproj
  • Neki-iOS/APP/Sources/Application/AppCoordinator.swift
  • Neki-iOS/APP/Sources/Application/Neki_iOSApp.swift
  • Neki-iOS/Core/Sources/ImagePicker/Domain/ImageUploadClient.swift
  • Neki-iOS/Features/Archive/Sources/Data/Sources/FileManagerSharedImageRepository.swift
  • Neki-iOS/Features/Archive/Sources/Domain/Sources/Client/SharedImageClient.swift
  • Neki-iOS/Features/Archive/Sources/Domain/Sources/Interfaces/Repositories/SharedImageRepository.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveFeature.swift
  • Neki-iOS/Neki-iOS-Debug.entitlements
  • Neki-iOS/Neki-iOS-Release.entitlements
  • NekiShareExtension/NekiShareExtension-Debug.entitlements
  • NekiShareExtension/NekiShareExtension-Release.entitlements
  • NekiShareExtension/Resources/Info.plist
  • NekiShareExtension/Sources/Data/Sources/FileManagerExtensionImageRepository.swift
  • NekiShareExtension/Sources/Domain/Sources/Entities/ShareExtensionConfiguration.swift
  • NekiShareExtension/Sources/Domain/Sources/Interfaces/ExtensionImageRepository.swift
  • NekiShareExtension/Sources/Domain/Sources/Utilities/ImageShareProcessor.swift
  • NekiShareExtension/Sources/Presentation/Sources/ShareViewController.swift

Comment on lines +189 to +198
func openMainApp(with url: URL) {
var responder: UIResponder? = self
while responder != nil {
if let application = responder as? UIApplication {
application.open(url)
break
}
responder = responder?.next
}
self.extensionContext?.completeRequest(returningItems: [])
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: 다른 프로젝트에서 share extension URL 열기 패턴 확인
rg -n "openURL" --type swift -C 3

Repository: YAPP-Github/27th-App-Team-2-iOS

Length of output: 15969


🏁 Script executed:

# Check for Share Extension code and ShareViewController
find . -type f -name "*ShareViewController*" -o -name "*Extension*" | grep -i share | head -20

Repository: YAPP-Github/27th-App-Team-2-iOS

Length of output: 548


🏁 Script executed:

# Look for existing extension implementations
fd -t f "ShareViewController" -x cat {}

Repository: YAPP-Github/27th-App-Team-2-iOS

Length of output: 8471


🏁 Script executed:

# Check the actual file structure
git ls-files | grep -i "extension" | head -20

Repository: YAPP-Github/27th-App-Team-2-iOS

Length of output: 858


🏁 Script executed:

# Look for any extension URL handling patterns
rg -l "extension" --type swift | head -10

Repository: YAPP-Github/27th-App-Team-2-iOS

Length of output: 787


🏁 Script executed:

# Check the entitlements for app groups
cat NekiShareExtension/NekiShareExtension-Debug.entitlements

Repository: YAPP-Github/27th-App-Team-2-iOS

Length of output: 385


🏁 Script executed:

# Check the Info.plist for URL schemes
cat NekiShareExtension/Resources/Info.plist | head -50

Repository: YAPP-Github/27th-App-Team-2-iOS

Length of output: 874


🏁 Script executed:

# Look for the deep link URL configuration
cat NekiShareExtension/Sources/Domain/Sources/Entities/ShareExtensionConfiguration.swift

Repository: YAPP-Github/27th-App-Team-2-iOS

Length of output: 809


🏁 Script executed:

# Check how the main app handles the deep link
rg -n "neki://shareExtension" -A 5 -B 5

Repository: YAPP-Github/27th-App-Team-2-iOS

Length of output: 1335


🏁 Script executed:

# Look for any URL scheme handling in the main app
rg -n "openURL\|handleURL\|URLScheme" --type swift -B 3 -A 3 | head -100

Repository: YAPP-Github/27th-App-Team-2-iOS

Length of output: 57


🏁 Script executed:

# Check if there's any existing pattern for opening URLs from extensions in main app
rg -n "extensionContext\|NSExtensionItem\|complete.*Request" --type swift | head -20

Repository: YAPP-Github/27th-App-Team-2-iOS

Length of output: 57


🏁 Script executed:

# Check how the main app handles the deep link for share extension
rg -n "shareExtension" --type swift -B 5 -A 5

Repository: YAPP-Github/27th-App-Team-2-iOS

Length of output: 2577


🏁 Script executed:

# Look for URL scheme handling in AppCoordinator or main app initialization
rg -n "scene.*didConnect\|application.*open.*url\|onOpenURL" --type swift -B 3 -A 5

Repository: YAPP-Github/27th-App-Team-2-iOS

Length of output: 57


🌐 Web query:

iOS Share Extension UIApplication access responder chain limitations

💡 Result:

In iOS Share Extensions (and app extensions generally), direct access to UIApplication.shared (formerly sharedApplication) is unavailable by design. Apple's official App Extension Programming Guide explicitly states: "An app extension cannot: Access a sharedApplication object, and so cannot use any of the methods on that object." This limitation exists because extensions run in a restricted environment hosted by another app, without their own full UIApplication instance. The responder chain in extensions follows the standard UIKit pattern (view -> superview -> view controller -> window -> UIApplication), but the UIApplication at the top is the host app's instance, not the extension's containing app's. Developers commonly traverse this chain starting from a view controller (e.g., self in ShareViewController) to reach UIApplication indirectly: @objc func openURL(_ url: URL) -> Bool { var responder: UIResponder? = self while responder != nil { if let application = responder as? UIApplication { return application.perform(#selector(openURL(:)), with: url) != nil } responder = responder?.next } return false } This hack works in practice for actions like openURL because it uses the host app's UIApplication to open URLs (e.g., custom schemes to launch the containing app). However, it violates Apple's documented restrictions, risks App Store rejection, and may fail in future iOS versions or specific hosts (e.g., deprecated openURL in iOS 18 logs warnings). Recent sources (2024) confirm it still functions despite deprecations by using updated open(:options:completionHandler:). Recommended alternatives: - Use NSExtensionContext.open(_:completionHandler:) to request URL opening via the host/system. - Share data via App Groups (UserDefaults or files) without launching the app. - Handle all logic within the extension, as intended by Apple. This policy has remained consistent since iOS 8, with no changes as of 2026.

Citations:


🏁 Script executed:

# Check if NSExtensionContext.open is available/used
rg -n "NSExtensionContext\|extensionContext.*open" --type swift -B 2 -A 2

Repository: YAPP-Github/27th-App-Team-2-iOS

Length of output: 57


Share Extension에서 responder chain을 통한 URL 열기는 신뢰할 수 없습니다.

Share Extension에서는 responder chain을 통해 호스트 앱의 UIApplication에 접근할 수 없습니다. Apple의 공식 문서에서 명시하듯이 extension은 직접 UIApplication에 접근할 수 없으며, 현재 코드는 실제로 main app을 열지 못할 수 있습니다. 제안된 selector 기반 방식도 마찬가지로 미지원 API에 의존하므로 문제가 있습니다.

공식적으로 권장되는 방식은 NSExtensionContext의 open(_:completionHandler:) 메서드를 사용하는 것입니다:

🔧 권장 수정안
 func openMainApp(with url: URL) {
-    var responder: UIResponder? = self
-    while responder != nil {
-        if let application = responder as? UIApplication {
-            application.open(url)
-            break
-        }
-        responder = responder?.next
-    }
+    self.extensionContext?.open(url, completionHandler: nil)
     self.extensionContext?.completeRequest(returningItems: [])
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func openMainApp(with url: URL) {
var responder: UIResponder? = self
while responder != nil {
if let application = responder as? UIApplication {
application.open(url)
break
}
responder = responder?.next
}
self.extensionContext?.completeRequest(returningItems: [])
func openMainApp(with url: URL) {
self.extensionContext?.open(url, completionHandler: nil)
self.extensionContext?.completeRequest(returningItems: [])
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@NekiShareExtension/Sources/Presentation/Sources/ShareViewController.swift`
around lines 189 - 198, The openMainApp(with:) implementation uses the responder
chain to find UIApplication (which is unsupported in extensions) so replace that
logic by calling NSExtensionContext's open(_:completionHandler:) on
self.extensionContext to open the URL; use the completion handler to call
self.extensionContext?.completeRequest(returningItems: []) (or handle failures
accordingly) and remove the responder-chain loop — update the function named
openMainApp(with:) to use extensionContext.open instead of searching for
UIApplication.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR 내용에 작성해두었듯이, iMessage, Today 위젯이 아닌 이상 NSExtensionContext의 open(_:completionHandler:) 메서드를 사용할 수 없습니다. 이는 공식문서에서도 확인할 수 있는 내용이며, 리스폰더 체인을 통한 DeepLink 열기 방식 밖에 사용할 수 없는 상황입니다. 따라서 이 리뷰는 잘못되었습니다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

@Remaked-Swain, 지적해 주셔서 감사합니다. 제 리뷰가 잘못되었습니다.

Apple의 공식 문서에 명시되어 있듯이, openURL:completionHandler: 메서드는 **Today 위젯(및 다른 extension 타입은 해당 없음)**만 호스팅 앱을 열기 위해 사용할 수 있습니다. 따라서 Share Extension에서는 NSExtensionContext.open(_:completionHandler:)를 사용할 수 없으며, 현재 구현하신 responder chain을 통한 방식이 Share Extension에서 컨테이닝 앱을 실행하는 올바른 접근 방식입니다. 제 이전 리뷰는 철회합니다.


🌐 Web query:

NSExtensionContext open(_:completionHandler:) share extension availability iOS

💡 Result:

NSExtensionContext.open(_:completionHandler:) exists, but it’s not generally available to Share extensions in the sense of “will successfully open your containing app.”

  • Apple’s App Extension Programming Guide states that only a Today widget can ask the system to open its containing app via NSExtensionContext.openURL:completionHandler:. [1]
  • Apple’s Today widget docs reiterate that widgets can open the containing app by calling openURL:completionHandler: on NSExtensionContext. [2]
  • Apple Developer Forums (quoting the NSExtensionContext.open API docs) notes that on iOS the extension points that support this are Today and iMessage, and iMessage has additional limits (can only open its parent app, and only under certain conditions). Share/Action extensions therefore typically get success == false. [3]

So, for a Share extension, calling extensionContext.open(url) is expected to fail; the supported pattern is to share data (e.g., App Group / file / pasteboard) and have the user open the app (or use other UX like a notification/deep link the user taps).

Sources: [1] [2] [3]


@Remaked-Swain, 검색 결과로도 확인했습니다. 지적이 맞습니다.

iOS에서 NSExtensionContext.open(_:completionHandler:)를 지원하는 extension point는 Today 위젯과 iMessage뿐이며, Share Extension에서 이를 호출하면 success == false가 반환됩니다. 따라서 현재 구현하신 responder chain을 통해 UIApplication을 찾아 URL을 여는 방식이 Share Extension에서 사용할 수 있는 올바른 접근법입니다. 잘못된 리뷰를 드려 죄송합니다.


✏️ Learnings added
Learnt from: Remaked-Swain
Repo: YAPP-Github/27th-App-Team-2-iOS PR: 187
File: NekiShareExtension/Sources/Presentation/Sources/ShareViewController.swift:189-198
Timestamp: 2026-03-23T11:56:52.440Z
Learning: In iOS Share Extensions (`com.apple.share-services`), `NSExtensionContext.open(_:completionHandler:)` is NOT available. According to Apple's official App Extension Programming Guide, only Today widgets (not any other app extension type) can use `openURL:completionHandler:` on NSExtensionContext to open the containing app. The correct workaround for Share Extensions to open the containing app via a deep link/URL scheme is to traverse the UIResponder chain to find the UIApplication instance and call `open(_:)` on it. This is the standard accepted approach.

Learnt from: Remaked-Swain
Repo: YAPP-Github/27th-App-Team-2-iOS PR: 187
File: NekiShareExtension/Sources/Presentation/Sources/ShareViewController.swift:189-198
Timestamp: 2026-03-23T11:56:52.440Z
Learning: In iOS Share Extensions (`com.apple.share-services`), `NSExtensionContext.open(_:completionHandler:)` is NOT available and will return `success == false`. According to Apple's official App Extension Programming Guide and developer forums, only Today widgets and iMessage extensions support `NSExtensionContext.open(_:completionHandler:)` to open the containing app. The correct workaround for Share Extensions to open the containing app via a deep link/URL scheme is to traverse the UIResponder chain (starting from `self` in `ShareViewController`) to find the `UIApplication` instance and call `open(_:)` on it. Do NOT suggest `NSExtensionContext.open(_:completionHandler:)` for Share Extensions.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

…into feat/#182-share-extension

# Conflicts:
#	Neki-iOS.xcodeproj/project.pbxproj
#	Neki-iOS/APP/Sources/Application/Neki_iOSApp.swift
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Neki-iOS.xcodeproj/project.pbxproj`:
- Around line 43-54: The PBXCopyFilesBuildPhase entry named "Embed Foundation
Extensions" (identifier 59235C5A2F64245A00198051) is defined but not referenced
by the Neki-iOS target's buildPhases; add the phase's reference into the
Neki-iOS target object's buildPhases array so the NekiShareExtension.appex
copy-files phase is executed and the .appex is embedded into the app bundle
PlugIns directory.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 41c79585-08b2-443e-81f1-46bc349ae299

📥 Commits

Reviewing files that changed from the base of the PR and between f4eda95 and 6bd0679.

📒 Files selected for processing (2)
  • Neki-iOS.xcodeproj/project.pbxproj
  • Neki-iOS/APP/Sources/Application/Neki_iOSApp.swift

Comment thread Neki-iOS.xcodeproj/project.pbxproj
Copy link
Copy Markdown
Member

@OneTen19 OneTen19 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수고하셨습니다!!!

이미지 공유 과정 중, 로그인 전이라면 로그인을 수행한 뒤에 공유 과정을 이어서 진행하도록 했습니다. 이러한 엣지케이스들을 더 발견하고 대응하여 안정성을 높여야 합니다. 놓친 부분이 있다거나 추천할만한 아이디어가 있다면 말씀해주세요!

테스트코드 작성해보는 거 어떰!?

SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3

오잉 쉐어 익스텐션은 스위프트5 쓰나보네

ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon_Dev;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Neki-iOS/Neki-iOS.entitlements";
CODE_SIGN_ENTITLEMENTS = "Neki-iOS/Neki-iOS-Debug.entitlements";
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p1

어 이거 파이어베이스 설정떄문에 debug, release 이름 동일하게 해둔건데 혹시 build phase 쪽 체크 되셨을까요!?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이건 xcconfig아니고 entitlements인데다가 아까 잘되는거 확인받았습니다!

"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.7;
MARKETING_VERSION = 1.2.0;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3

아 우리 스프린트 끝나면 1.2.0 으로 출시할라캤는디 2.0.0 으로 해야겠군

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 아직 테플이든 빌드제출 안했으니까 1.2.0인 상태로 그대로 내비뒀다가 올리면 1.2.0으로 출ㄹ시된다 아님?

Comment on lines +29 to 40
private func handleIncomingURL(_ url: URL) {
if url.scheme == "neki" {
store.send(.onOpenURL(url))
return
}

if url.scheme == "https" || url.scheme == "http" {
guard let host = url.host(), host == "neki.suitestudy.com" else { return }
return
}
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3

어 이거 익스텐션으로 분리해뒀었는데 다시 돌아왔다. 작동상 차이가 있나요??

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

무슨 마ㄹ인지 모루겟소용
NekiApp의 익스텐션으로 메서드를 정리해뒀었다는 얘기인가요?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 맞어유

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정리했던 버전의 코드는 못봤는데 그래서 되돌린 적도 없긴함ㄴ다

Comment on lines +27 to +56
uploadConcurrentlyFromURLs: { fileURLs, mediaType in
return try await withThrowingTaskGroup(of: [Int].self) { group in
var uploadedMediaIDs: [Int] = []
let maxConcurrentTasks = 3
var urlIterator = fileURLs.makeIterator()

for _ in 0..<maxConcurrentTasks {
guard let url = urlIterator.next() else { break }
group.addTask {
let data = try Data(contentsOf: url)
let entity = ImageUploadEntity(data: data, format: data.detectedImageFormat)
return try await repository.upload(items: [entity], mediaType: mediaType)
}
}

for try await resultIDs in group {
uploadedMediaIDs.append(contentsOf: resultIDs)

guard let nextURL = urlIterator.next() else { continue }
group.addTask {
let data = try Data(contentsOf: nextURL)
let entity = ImageUploadEntity(data: data, format: data.detectedImageFormat)
return try await repository.upload(items: [entity], mediaType: mediaType)
}
}

return uploadedMediaIDs
}
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p1

제 해석이 틀린 부분 있다면 알려주세요!!

메모리 혹은 여타 성능적인 최적화를 위해 한 번에 실행하는 작업의 개수를 3개로 제한해두고 반복자 패턴으로 작업을 추가해주면서 진행되는 것으로 보입니다. 반복자 패턴의 쓰임새를 눈으로 확인할 수 있어서 매우 좋았는데요, 한 가지 뭔가 걸리는 부분은 매 작업마다 레포지토리의 upload를 호출하고 있는 게 조금 의아하게 느껴집니다!

repository.upload 는 한 번에 여러 개의 ImageUploadEntity를 받아서 한 번에 PresignedURL들을 발급받아 S3 업로드를 진행하게 설계했습니다!! 즉, 10개의 이미지를 업로드 한다고 하면 10개의 엔티티를 upload 요청 1번으로 수행할 수 있는데요, 지금 구조는 매 작업마다 PresignedURL을 발급받고 S3 업로드 하는 과정을 반복하고 있습니다. 그래서 이미지 10개를 업로드 하려면 PresignedURL 발급 요청을 10번 별개로 보내고 있는 것 같아 보여요.

그래서 아래의 convert 케이스처럼 ImageUploadEntity 들을 배열로 지닌 다음 한 번의 repository.upload 요청을 보내는 구조로 바꾸는 건 어떻게 생각하시는지 궁금합니다!

이런 식으로요!

            uploadConcurrentlyFromURLs: { fileURLs, mediaType in
                try await withThrowingTaskGroup(of: ImageUploadEntity?.self) { group in
                    var entities: [ImageUploadEntity] = []
                    let maxConcurrentTasks = 3
                    var urlIterator = fileURLs.makeIterator()
                    
                    for _ in 0..<maxConcurrentTasks {
                        guard let url = urlIterator.next() else { break }
                        group.addTask {
                            let data = try Data(contentsOf: url)
                            return ImageUploadEntity(data: data, format: data.detectedImageFormat)
                        }
                    }
                    
                    for try await entity in group {
                        guard let entity else { continue }
                        entities.append(entity)
                        
                        guard let nextURL = urlIterator.next() else { continue }
                        group.addTask {
                            let data = try Data(contentsOf: nextURL)
                            return ImageUploadEntity(data: data, format: data.detectedImageFormat)
                        }
                    }
                    
                    return try await repository.upload(items: entities, mediaType: mediaType)
                }
            },

또한 repository.upload 메서드의 반환 타입이 우리 서버로 업로드 된 이미지의 id 리스트이기 때문에
uploadedMediaIDs 를 별도로 들고 리턴에 사용할 필요 없이 바로 repository.upload 를 리턴으로 반환해도 될 것 같습니다!!

Copy link
Copy Markdown
Member Author

@Remaked-Swain Remaked-Swain Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 코딩하다가 로직이 의도한 바와 달라져서 조금 잘못됐네요.
최대 10장의 이미지를 한번에 Data로 불러왔을 때, 최악의 경우 메모리 부족으로 앱크래시 날까봐 3장씩 쪼개서 업로드를 하고, 업로드가 끝난 하나의 작업은 즉시 메모리에서 해제시켜서 메모리 부족 상황이 오지 않도록 하려는 거였어요.
쉽게 말하면 3장, 3장, 3장, 1장으로 쪼개서, 4개 그룹에 대한 이미지들의 처리는 Repository에서 병렬적으로 업로드 수행하되, 그룹끼리는 순차적으로 처리입니다. 이렇게 최대 호출횟수 4회 안에 업로드를 따로따로 진행하는게 목표였습니다.
원래 의도에 맞도록 로직을 살짝 수정하겠습니다.


그와 별개로 위의 OOM으로 인한 크래시를 막기 위한 방어코드를 쓰면서도 결과를 한번에 반환하기 위해서는 uploadedMediaIDs 로컬 변수에 누적해야합니다.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 10장의 데이터를 메모리에서 쥐고 있지 않는 걸 의도하신거군요
이해했습니다!


ㅇㄴ 그럼 기존 업로드 쪽이랑은 작동방식이 달라지는 군. 나중에 통일하는 게 좋으려나

import UniformTypeIdentifiers
import os

public struct FileManagerSharedImageRepository {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3

우리 컨벤션인 Default 접두사보다
FileManager를 사용하는 클라이언트이니까 FileManagerSharedImageRepository 가 더 명확하다고 판단해서 요렇게 네이밍 하신 걸까요?!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정확합니다. 다른 Repository는 구현기반이 변경될 예정이 없었는데, 이 친구는 FileManager 외의 다른 영속성 모델로도 구현될 수도 있을 것 같다고 판단해서 네이밍했습니다.
그러한 구현계획이 현재로선 없으므로 Default 접두사를 붙여 이름을 다시 지을까요?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

음 아녀 지금도 좋은 것 같습니다

Comment on lines +19 to +25
private func sharedImageURLs(for appGroupID: String) throws -> [URL] {
guard let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) else { return [] }
let sharedDirectoryURL = groupURL.appendingPathComponent("SharedImages", conformingTo: .directory)

guard fileManager.fileExists(atPath: sharedDirectoryURL.path()) else { return [] }
return try fileManager.contentsOfDirectory(at: sharedDirectoryURL, includingPropertiesForKeys: nil)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3

파일매니저라는 거 첨 알았슴. 갈 길이 멀다

Comment on lines +18 to +22
protocol ImageShareUseCase {
func extractImageProviders(from extensionItems: [NSExtensionItem]) -> [NSItemProvider]
func fetchPreviewData(from provider: NSItemProvider) async throws -> Data
func share(providers: [NSItemProvider]) async throws
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3

client가 아닌 usecase 형태를 채택하셨는데, 이유가 있을까요???
아직 클라이언트, 유즈케이스에 대한 이해가 부족해서 사용 이유와 판단법 및 근거 등등 알고 싶음...
그리고 Processor 라는 구조도 종종 사용하시던데 이런 거 정하는 기준 같은 게 따로 있으실까요??? 난 오로지 유즈케이스, 클라이언트밖에 몰라

Copy link
Copy Markdown
Member Author

@Remaked-Swain Remaked-Swain Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

개념 자체는 다 똑같다고 생각합니다.
그래서 그걸 특정한 고정형이 있는 것으로 인식하면 더 어려워질 것 같다는 생각이 드네요... (제가 그랬듯이요)
비즈니스 로직을 표현하는 타입이라는 점에서 모두 동일하구요. 네이밍 자체에 대한 나름의 기준이 있냐고 물으시면, 음... 여기 공유 확장 타겟에는 TCA가 없으니까 Client는 더 헷갈리게 만들거같아서 제외, Processor라고 하기에는 저수준 프레임워크에 의존하거나 복잡한 로직은 없음(이미지 다운샘플링 프로세서를 예시로 들게요!) 그래서 제외... 그러다가 문득 유즈케이스라는 네이밍이 번뜩 들어서 걍 그걸로 지엇음요

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3

캬 오랜만에 보는 UIKit

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

붐따

}

func setupLayout() {
NSLayoutConstraint.activate([
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3

와 맨날 스냅킷만 써서 NSLayoutConstraint 되게 오랜만에 보네

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스냅킷 붐따
나약한자들의 라이브러리 흥칫

@Remaked-Swain Remaked-Swain requested a review from OneTen19 March 24, 2026 16:23
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
Neki-iOS/Features/Archive/Sources/Data/Sources/FileManagerSharedImageRepository.swift (1)

36-46: 정리 작업 결과 요약 로깅 추가 고려

현재 구현은 개별 파일 삭제 실패 시에도 계속 진행하는데, 이는 정리 로직으로서 적절한 접근입니다. 다만 디버깅과 모니터링을 위해 정리 작업의 전체 결과(성공/실패 개수)를 요약 로깅하면 운영 시 유용할 수 있습니다.

📊 정리 결과 요약 로깅 제안
 public func clearSharedImages(appGroupID: String) async throws {
     let fileURLs = try sharedImageURLs(for: appGroupID)
     
+    var failedCount = 0
     for url in fileURLs {
         do {
             try fileManager.removeItem(at: url)
         } catch {
+            failedCount += 1
             Logger.data.error("파일 정리 실패: \(url.lastPathComponent) - \(error.localizedDescription)")
         }
     }
+    
+    if failedCount > 0 {
+        Logger.data.warning("공유 이미지 정리 완료: \(fileURLs.count - failedCount)/\(fileURLs.count) 성공")
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Data/Sources/FileManagerSharedImageRepository.swift`
around lines 36 - 46, Enhance clearSharedImages(appGroupID:) to record and log a
summary of the cleanup outcome: keep counters for total files, successful
deletions, and failures while iterating over URLs returned by
sharedImageURLs(for:), incrementing successes in the try block and failures in
the catch (which should still log per-file errors as currently done), then after
the loop emit a single summary log via Logger.data (e.g., include appGroupID,
total count, successes, failures) so operators can quickly see overall cleanup
results; keep existing per-file error logging unchanged and reference the
clearSharedImages method, sharedImageURLs(for:), fileManager.removeItem(at:),
and Logger.data in your changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@Neki-iOS/Features/Archive/Sources/Data/Sources/FileManagerSharedImageRepository.swift`:
- Around line 19-25: In sharedImageURLs(for:) don’t silently return [] when
fileManager.containerURL(forSecurityApplicationGroupIdentifier:) is nil—replace
the silent failure with a thrown/descriptive error (or at least a logged error)
so App Group misconfiguration is distinguishable from “no images”; also remove
the fileExists(atPath:) pre-check to avoid TOCTOU and instead call
fileManager.contentsOfDirectory(at:includingPropertiesForKeys:) directly and
handle errors: if the error is “directory not found” return [] otherwise rethrow
or propagate the error. Use the function name sharedImageURLs, and the calls
containerURL(forSecurityApplicationGroupIdentifier:), fileExists(atPath:), and
contentsOfDirectory(at:includingPropertiesForKeys:) to locate where to change
behavior.

---

Nitpick comments:
In
`@Neki-iOS/Features/Archive/Sources/Data/Sources/FileManagerSharedImageRepository.swift`:
- Around line 36-46: Enhance clearSharedImages(appGroupID:) to record and log a
summary of the cleanup outcome: keep counters for total files, successful
deletions, and failures while iterating over URLs returned by
sharedImageURLs(for:), incrementing successes in the try block and failures in
the catch (which should still log per-file errors as currently done), then after
the loop emit a single summary log via Logger.data (e.g., include appGroupID,
total count, successes, failures) so operators can quickly see overall cleanup
results; keep existing per-file error logging unchanged and reference the
clearSharedImages method, sharedImageURLs(for:), fileManager.removeItem(at:),
and Logger.data in your changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b40ac03f-91e4-4706-8846-6cefe16ebcfe

📥 Commits

Reviewing files that changed from the base of the PR and between e654d03 and a5e27de.

📒 Files selected for processing (3)
  • Neki-iOS/Core/Sources/ImagePicker/Domain/ImageUploadClient.swift
  • Neki-iOS/Features/Archive/Sources/Data/Sources/FileManagerSharedImageRepository.swift
  • Neki-iOS/Features/Archive/Sources/Domain/Sources/Interfaces/Repositories/SharedImageRepository.swift
✅ Files skipped from review due to trivial changes (1)
  • Neki-iOS/Features/Archive/Sources/Domain/Sources/Interfaces/Repositories/SharedImageRepository.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • Neki-iOS/Core/Sources/ImagePicker/Domain/ImageUploadClient.swift

Comment on lines +19 to +25
private func sharedImageURLs(for appGroupID: String) throws -> [URL] {
guard let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) else { return [] }
let sharedDirectoryURL = groupURL.appendingPathComponent("SharedImages", conformingTo: .directory)

guard fileManager.fileExists(atPath: sharedDirectoryURL.path()) else { return [] }
return try fileManager.contentsOfDirectory(at: sharedDirectoryURL, includingPropertiesForKeys: nil)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

App Group 컨테이너 접근 실패 시 조용한 실패 처리

Line 20에서 containerURLnil인 경우 빈 배열을 반환하고 있습니다. App Group이 잘못 구성되었거나 entitlements 설정 오류가 있을 때 디버깅이 어려워질 수 있습니다. 실제 구성 오류와 단순히 공유된 이미지가 없는 상황을 구분하기 어렵습니다.

또한 Line 23-24에서 fileExists 체크와 contentsOfDirectory 호출 사이에 TOCTOU(Time-of-Check to Time-of-Use) 경쟁 조건이 발생할 수 있습니다. 디렉토리가 존재하지 않을 때 contentsOfDirectory가 던지는 에러를 직접 처리하는 것이 더 안전합니다.

🛡️ 제안하는 개선안
 private func sharedImageURLs(for appGroupID: String) throws -> [URL] {
-    guard let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) else { return [] }
+    guard let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) else {
+        Logger.data.error("App Group 컨테이너 접근 실패: \(appGroupID)")
+        return []
+    }
     let sharedDirectoryURL = groupURL.appendingPathComponent("SharedImages", conformingTo: .directory)
     
-    guard fileManager.fileExists(atPath: sharedDirectoryURL.path()) else { return [] }
-    return try fileManager.contentsOfDirectory(at: sharedDirectoryURL, includingPropertiesForKeys: nil)
+    do {
+        return try fileManager.contentsOfDirectory(at: sharedDirectoryURL, includingPropertiesForKeys: nil)
+    } catch CocoaError.fileReadNoSuchFile, CocoaError.fileNoSuchFile {
+        return []
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Data/Sources/FileManagerSharedImageRepository.swift`
around lines 19 - 25, In sharedImageURLs(for:) don’t silently return [] when
fileManager.containerURL(forSecurityApplicationGroupIdentifier:) is nil—replace
the silent failure with a thrown/descriptive error (or at least a logged error)
so App Group misconfiguration is distinguishable from “no images”; also remove
the fileExists(atPath:) pre-check to avoid TOCTOU and instead call
fileManager.contentsOfDirectory(at:includingPropertiesForKeys:) directly and
handle errors: if the error is “directory not found” return [] otherwise rethrow
or propagate the error. Use the function name sharedImageURLs, and the calls
containerURL(forSecurityApplicationGroupIdentifier:), fileExists(atPath:), and
contentsOfDirectory(at:includingPropertiesForKeys:) to locate where to change
behavior.

Copy link
Copy Markdown
Member

@OneTen19 OneTen19 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM👍

@Remaked-Swain Remaked-Swain merged commit 9815fbc into develop Mar 25, 2026
1 check passed
@Remaked-Swain Remaked-Swain deleted the feat/#182-share-extension branch March 25, 2026 12:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Add ✚ 코드, 파일, 에셋 추가 Chore 🪡 자잘한 코드 수정 Feat 💻 기능 구현 Style 🖼️ UI 작업 금용 🐲 금용 작업

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] Share Extension을 통한 외부 앱 네컷사진 저장 기능 추가

2 participants