Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions TOASTER-iOS.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1911,7 +1911,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1530;
LastUpgradeCheck = 1620;
LastUpgradeCheck = 2630;
TargetAttributes = {
3FF2BEFE2BA17492001D7DC1 = {
CreatedOnToolsVersion = 15.3;
Expand Down Expand Up @@ -2343,7 +2343,6 @@
CODE_SIGN_ENTITLEMENTS = ToasterShareExtension/ToasterShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = JF6B89ULG6;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ToasterShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ToasterShareExtension;
Expand All @@ -2370,7 +2369,6 @@
CODE_SIGN_ENTITLEMENTS = ToasterShareExtension/ToasterShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = JF6B89ULG6;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ToasterShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ToasterShareExtension;
Expand Down Expand Up @@ -2427,6 +2425,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = JF6B89ULG6;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
Expand All @@ -2450,8 +2449,11 @@
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_STRICT_CONCURRENCY = complete;
};
name = Debug;
};
Expand Down Expand Up @@ -2491,6 +2493,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = JF6B89ULG6;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
Expand All @@ -2507,7 +2510,10 @@
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
SWIFT_STRICT_CONCURRENCY = complete;
VALIDATE_PRODUCT = YES;
};
name = Release;
Expand All @@ -2521,7 +2527,6 @@
CODE_SIGN_ENTITLEMENTS = "TOASTER-iOS/TOASTER-iOS.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = JF6B89ULG6;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "TOASTER-iOS/Global/Supporting Files/Info.plist";
Expand All @@ -2535,7 +2540,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.2;
MARKETING_VERSION = 1.2.3;
PRODUCT_BUNDLE_IDENTIFIER = "TeamLinkMIND.TOASTER-iOS";
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
Expand All @@ -2555,7 +2560,6 @@
CODE_SIGN_ENTITLEMENTS = "TOASTER-iOS/TOASTER-iOS.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = JF6B89ULG6;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "TOASTER-iOS/Global/Supporting Files/Info.plist";
Expand All @@ -2569,7 +2573,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.2;
MARKETING_VERSION = 1.2.3;
PRODUCT_BUNDLE_IDENTIFIER = "TeamLinkMIND.TOASTER-iOS";
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1620"
LastUpgradeVersion = "2630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
Expand Down
64 changes: 37 additions & 27 deletions TOASTER-iOS/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
UserDefaults.standard.set(true, forKey: "isAppAlarmOn")

// MARK: - 카카오 로그인 설정

KakaoSDK.initSDK(appKey: Config.kakaoNativeAppKey)

let result = KeyChainService.loadTokens(accessKey: Config.accessTokenKey, refreshKey: Config.refreshTokenKey)
Expand All @@ -50,8 +50,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate {

switch loginType {
case Config.appleLogin:
checkAppleLogin { [weak self] result in
self?.isLogin = result
Task { [weak self] in
let result = await self?.checkAppleLogin() ?? false
await MainActor.run { [weak self] in
self?.isLogin = result
}
}
Comment on lines +53 to 58
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 | 🟠 Major

Potential race condition with isLogin state.

The isLogin property is set asynchronously inside a Task, but didFinishLaunchingWithOptions returns true immediately. If SceneDelegate or other code reads isLogin before the Apple login check completes, it may see a stale value.

Consider using a more robust pattern such as:

  • A @Published property with async observation
  • A completion-based initialization flow
  • Delaying scene setup until login state is determined
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TOASTER-iOS/Application/AppDelegate.swift` around lines 53 - 58, The async
Task updating isLogin can race with scene setup; make isLogin an observable
state and notify consumers instead of silently setting it: change isLogin to a
`@Published` (or `@MainActor` `@Published`) property on AppDelegate, keep
checkAppleLogin() async, but after awaiting its result publish the value (or
post a NotificationCenter notification like .loginStateUpdated) from the Task,
and update SceneDelegate (or other consumers) to observe
AppDelegate.shared.$isLogin or subscribe to the notification and defer UI/setup
until the observed value arrives; ensure SceneDelegate uses the published
subscription (or notification callback) to react to login state rather than
reading isLogin synchronously during didFinishLaunchingWithOptions.

case Config.kakaoLogin:
checkKakaoLogin { [weak self] result in
Expand Down Expand Up @@ -108,48 +111,55 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
}

func checkAppleLogin(completion: @escaping (Bool) -> Void) {
func checkAppleLogin() async -> Bool {
let appleIDProvider = ASAuthorizationAppleIDProvider()
appleIDProvider.getCredentialState(forUserID: UserDefaults.standard.string(forKey: Config.appleUserID) ?? "") { (credentialState, error) in
switch credentialState {
case .authorized:
print("해당 ID는 연동되어있습니다.")
completion(true)
case .revoked:
print("해당 ID는 연동되어있지않습니다.")
completion(false)
case .notFound:
print("해당 ID를 찾을 수 없습니다.")
completion(false)
default:
break
return await withCheckedContinuation { continuation in
appleIDProvider.getCredentialState(forUserID: UserDefaults.standard.string(forKey: Config.appleUserID) ?? "") { credentialState, _ in
switch credentialState {
case .authorized:
print("해당 ID는 연동되어있습니다.")
continuation.resume(returning: true)
case .revoked:
print("해당 ID는 연동되어있지 않습니다.")
continuation.resume(returning: false)
case .notFound:
print("해당 ID를 찾을 수 없습니다.")
continuation.resume(returning: false)
default:
continuation.resume(returning: false)
}
}
}
}
}

// MARK: - MessagingDelegate

extension AppDelegate: MessagingDelegate {

// FCM 토큰을 받았을 때 실행
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
nonisolated func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
if let fcmToken {
let _ = KeyChainService.saveFCMToken(fcmToken: fcmToken, key: Config.fcmTokenKey)
}
}
}

extension AppDelegate: UNUserNotificationCenterDelegate {

func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {

if let navigationViewController = UIApplication.shared.keyWindow?.rootViewController as? ToasterNavigationController {
navigationViewController.popToRootViewController(animated: false)
if let tabBarController = navigationViewController.topViewController as? TabBarController {
tabBarController.selectedIndex = 0
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
Task { @MainActor in
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let navigationViewController = windowScene.windows
.first(where: { $0.isKeyWindow })?
.rootViewController as? ToasterNavigationController {
navigationViewController.popToRootViewController(animated: false)
if let tabBarController = navigationViewController.topViewController as? TabBarController {
tabBarController.selectedIndex = 0
}
}
}
completionHandler()
}
Comment on lines +149 to 164
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 | 🟠 Major

completionHandler called before UI work completes.

The completionHandler() at line 163 is called immediately after spawning the Task, not after the UI navigation completes. This signals to the system that notification handling is finished while the navigation may still be in progress.

Consider awaiting the Task completion or moving the completion handler inside the Task:

🐛 Proposed fix
 nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter,
                                         didReceive response: UNNotificationResponse,
                                         withCompletionHandler completionHandler: `@escaping` () -> Void) {
     Task { `@MainActor` in
         if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
            let navigationViewController = windowScene.windows
             .first(where: { $0.isKeyWindow })?
             .rootViewController as? ToasterNavigationController {
             navigationViewController.popToRootViewController(animated: false)
             if let tabBarController = navigationViewController.topViewController as? TabBarController {
                 tabBarController.selectedIndex = 0
             }
         }
+        completionHandler()
     }
-    completionHandler()
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TOASTER-iOS/Application/AppDelegate.swift` around lines 149 - 164, The
notification handler currently calls completionHandler() immediately after
spawning a Task, which can return to the system before the UI navigation
(popToRootViewController and tabBarController.selectedIndex assignment)
finishes; fix it by moving the completionHandler() into the Task so it runs
after the UI work completes (e.g., perform the UIWindowScene lookup and calls to
ToasterNavigationController.popToRootViewController(...) and
TabBarController.selectedIndex = 0 on the `@MainActor` Task and then call
completionHandler() at the end), or alternatively await the Task's completion
before calling completionHandler() so the UI updates finish first.

}
1 change: 1 addition & 0 deletions TOASTER-iOS/Application/Coordinator/Coordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import Foundation

@MainActor
protocol Coordinator: AnyObject {
var childCoordinators: [Coordinator] { get set }
func start()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import Foundation

@MainActor
protocol CoordinatorFinishOutput {
var onFinish: (() -> Void)? { get set }
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@ private extension TabBarCoordinator {
accessKey: Config.accessTokenKey,
refreshKey: Config.refreshTokenKey
)
self?.onFinish?()
Task { @MainActor in
self?.onFinish?()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import UIKit

@MainActor
protocol CoordinatorFactoryProtocol {
func makeTabBarCoordinator(
router: RouterProtocol,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import Foundation

@MainActor
protocol ViewControllerFactoryProtocol {
func makeLoginVC() -> LoginViewController
func makeTabBarVC() -> TabBarController
Expand Down
1 change: 1 addition & 0 deletions TOASTER-iOS/Application/Coordinator/Router.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import UIKit

@MainActor
protocol RouterProtocol: AnyObject {
func setRoot(_ viewController: UIViewController, animated: Bool)
func setRoot(_ viewController: UIViewController, animated: Bool, hideBottomBarWhenPushed: Bool)
Expand Down
2 changes: 1 addition & 1 deletion TOASTER-iOS/Global/Resources/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ enum Config {
}
}

private static let infoDictionary: [String: Any] = {
nonisolated(unsafe) private static let infoDictionary: [String: Any] = {
guard let dict = Bundle.main.infoDictionary else {
fatalError("plist cannot found !!!")
}
Expand Down
2 changes: 1 addition & 1 deletion TOASTER-iOS/Network/Base/APIInterceptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ final class APIInterceptor: RequestInterceptor {
/// 재발급을 받고 다시 기존 API 를 호출해야하기 때문에 Requset 의 Header 를 변경해야 한다.
/// 해당 프로퍼티가 true 일 경우 기존의 Requset 의 Header 를 변경
/// 재발급을 받은 적이 없는 false 의 경우 TargetType 을 통해 Header 를 설정한 Requset 를 기본으로 사용
private var isTokenRefreshed = false
nonisolated(unsafe) private var isTokenRefreshed = false
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -name "APIInterceptor.swift" -type f

Repository: Link-MIND/TOASTER-iOS

Length of output: 112


🏁 Script executed:

cat -n TOASTER-iOS/Network/Base/APIInterceptor.swift

Repository: Link-MIND/TOASTER-iOS

Length of output: 4203


Remove the nonisolated(unsafe) flag and the shared mutable refresh state entirely.

The singleton APIInterceptor has a genuine data race: adapt() reads and clears isTokenRefreshed at lines 31–36, while retry() writes it at line 72. When multiple requests retry concurrently after a 401, the first adapt() clears the flag, causing subsequent overlapping retries to skip the token refresh. The nonisolated(unsafe) annotation only silences the compiler warning—it doesn't prevent the race.

Suggested fix

Remove the flag and instead read the token from KeyChain on every request:

 final class APIInterceptor: RequestInterceptor {
-    nonisolated(unsafe) private var isTokenRefreshed = false

     static let shared = APIInterceptor()

     func adapt(_ urlRequest: URLRequest, for session: Session, completion: `@escaping` (Result<URLRequest, Error>) -> Void) {
         print("adapt 진입")
         
         var modifiedRequest = urlRequest
-        if isTokenRefreshed {
-            print("토큰 재발급 후 URLRequset 변경")
-            modifiedRequest.setValue(KeyChainService.loadAccessToken(key: Config.accessTokenKey), forHTTPHeaderField: "accessToken")
-            
-            isTokenRefreshed = false
-        }
+        modifiedRequest.setValue(KeyChainService.loadAccessToken(key: Config.accessTokenKey), forHTTPHeaderField: "accessToken")
         completion(.success(modifiedRequest))
     }

     func retry(_ request: Request, for session: Session, dueTo error: Error, completion: `@escaping` (RetryResult) -> Void) {
         NetworkService.shared.authService.postRefreshToken { [weak self] result in
             switch result {
             case .success(let result):
                 guard let serverAccessToken = result?.data.accessToken, let serverRefreshToken = result?.data.refreshToken else {
                     completion(.doNotRetry)
                     return
                 }
                 
                 let keyChainResult = KeyChainService.saveTokens(accessKey: serverAccessToken, refreshKey: serverRefreshToken)
                 
                 if keyChainResult.accessResult == true && keyChainResult.refreshResult == true {
-                    self?.isTokenRefreshed = true
-                    
                     guard request.request != nil else {
                         completion(.doNotRetry)
                         return
                     }
                     
                     completion(.retry)
                 } else {
                     completion(.doNotRetry)
                 }
             default:
                 completion(.doNotRetry)
             }
         }
     }
 }

This eliminates the race by using KeyChain as the single source of truth for the token, ensuring all concurrent requests use the latest refreshed token.

📝 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
nonisolated(unsafe) private var isTokenRefreshed = false
final class APIInterceptor: RequestInterceptor {
static let shared = APIInterceptor()
func adapt(_ urlRequest: URLRequest, for session: Session, completion: `@escaping` (Result<URLRequest, Error>) -> Void) {
print("adapt 진입")
var modifiedRequest = urlRequest
modifiedRequest.setValue(KeyChainService.loadAccessToken(key: Config.accessTokenKey), forHTTPHeaderField: "accessToken")
completion(.success(modifiedRequest))
}
func retry(_ request: Request, for session: Session, dueTo error: Error, completion: `@escaping` (RetryResult) -> Void) {
NetworkService.shared.authService.postRefreshToken { [weak self] result in
switch result {
case .success(let result):
guard let serverAccessToken = result?.data.accessToken, let serverRefreshToken = result?.data.refreshToken else {
completion(.doNotRetry)
return
}
let keyChainResult = KeyChainService.saveTokens(accessKey: serverAccessToken, refreshKey: serverRefreshToken)
if keyChainResult.accessResult == true && keyChainResult.refreshResult == true {
guard request.request != nil else {
completion(.doNotRetry)
return
}
completion(.retry)
} else {
completion(.doNotRetry)
}
default:
completion(.doNotRetry)
}
}
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TOASTER-iOS/Network/Base/APIInterceptor.swift` at line 19, Remove the
nonisolated(unsafe) private var isTokenRefreshed and all uses of that shared
mutable flag in APIInterceptor; instead have adapt() always read the current
token from KeyChain (or the secure token store) at the start of each request and
apply it, and have retry() perform the refresh and persist the new token back to
KeyChain (without toggling any shared boolean). Update adapt() and retry() so
adapt() only reads KeyChain for the token, and retry() writes the refreshed
token to KeyChain (and triggers any necessary re-attempts) to eliminate the race
between adapt() and retry().


static let shared = APIInterceptor()

Expand Down
2 changes: 1 addition & 1 deletion TOASTER-iOS/Network/Base/NetworkService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Foundation

final class NetworkService {

static let shared = NetworkService()
nonisolated(unsafe) static let shared = NetworkService()
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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -A40 -B2 --type swift \
'protocol\s+(AuthAPIServiceProtocol|UserAPIServiceProtocol|ToasterAPIServiceProtocol|ClipAPIServiceProtocol|SearchAPIServiceProtocol|TimerAPIServiceProtocol|PopupAPIServiceProtocol)\b|(?:final\s+)?(?:class|actor|struct)\s+(AuthAPIService|UserAPIService|ToasterAPIService|ClipAPIService|SearchAPIService|TimerAPIService|PopupAPIService)\b|@unchecked Sendable|Sendable' \
TOASTER-iOS

Repository: Link-MIND/TOASTER-iOS

Length of output: 33752


🏁 Script executed:

#!/bin/bash
# Check BaseAPIService for any Sendable/actor annotations
rg -n -A20 "class BaseAPIService" --type swift TOASTER-iOS

Repository: Link-MIND/TOASTER-iOS

Length of output: 1971


🏁 Script executed:

#!/bin/bash
# Check NetworkService.shared full context and declaration
rg -n -B5 -A5 "nonisolated.*shared.*NetworkService" --type swift TOASTER-iOS

Repository: Link-MIND/TOASTER-iOS

Length of output: 869


nonisolated(unsafe) suppresses compile-time safety without proof of thread-safety.

The concrete services exposed through shared are reference types without Sendable conformance or actor isolation. Every service (AuthAPIService, UserAPIService, ToasterAPIService, ClipAPIService, SearchAPIService, TimerAPIService, PopupAPIService) is a final class inheriting from non-Sendable BaseAPIService, with a non-Sendable MoyaProvider property. This converts strict concurrency from a compiler guarantee into a manual audit burden. Prove the service graph is thread-safe or use actor isolation instead of opting out of safety checks.

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

In `@TOASTER-iOS/Network/Base/NetworkService.swift` at line 12, Replace the unsafe
opt-out by making NetworkService actor-isolated or by proving Sendable for all
contained services; specifically remove nonisolated(unsafe) static let shared =
NetworkService() and instead (a) convert NetworkService to an actor (actor
NetworkService) and expose a single isolated shared instance, or (b) ensure
every concrete service (AuthAPIService, UserAPIService, ToasterAPIService,
ClipAPIService, SearchAPIService, TimerAPIService, PopupAPIService and their
BaseAPIService superclass) and their MoyaProvider properties conform to Sendable
and are implemented thread-safe, then reintroduce a Sendable-safe shared; update
callers to await or hop to the actor as needed to preserve isolation.


private init() {}

Expand Down
7 changes: 5 additions & 2 deletions TOASTER-iOS/Present/AddLink/AddLinkViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -402,8 +402,11 @@ private extension AddLinkViewController {
loadingTitle: "저장 중...",
loadingAnimationSize: 16,
task: { _ in
DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.requestSaveLink.send()
Task {
try? await Task.sleep(for: .seconds(0.5))
await MainActor.run { [weak self] in
self?.requestSaveLink.send()
}
}
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import UIKit
import SnapKit
import Then

@MainActor
protocol SelectClipHeaderViewlDelegate: AnyObject {
func addClipCellTapped()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import UIKit
import SnapKit
import Then

@MainActor
protocol AddClipBottomSheetViewDelegate: AnyObject {
func addHeightBottom()
func minusHeightBottom()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import UIKit
import SnapKit
import Then

@MainActor
protocol ClipCollectionHeaderViewDelegate: AnyObject {
func addClipButtonTapped()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Kingfisher
import SnapKit
import Then

@MainActor
protocol DetailClipListCollectionViewCellDelegate: AnyObject {
func modifiedButtonTapped(toastId: Int)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,12 @@ private extension ChangeClipBottomSheetView {
loadingTitle: "이동 중...",
loadingAnimationSize: 16,
task: { completion in
DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) {
self.delegate?.completButtonTap()
completion()
Task {
try? await Task.sleep(for: .seconds(0.5))
Comment on lines +112 to +113
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'IPHONEOS_DEPLOYMENT_TARGET' TOASTER-iOS.xcodeproj/project.pbxproj
rg -n 'Task\.sleep\(for:' TOASTER-iOS/Present/DetailClip/View/Component/ChangeClipBottomSheetView.swift

Repository: Link-MIND/TOASTER-iOS

Length of output: 394


Use Task.sleep(nanoseconds:) or add an @available guard for iOS 16.0+.

The project has deployment targets as low as iOS 15.0, but Task.sleep(for:) requires iOS 16.0+. This code will fail to compile for the iOS 15.0 target. Either replace with Task.sleep(nanoseconds: 500_000_000) (available since iOS 13.0) or wrap the entire Task block in an @available(iOS 16.0, *) guard.

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

In
`@TOASTER-iOS/Present/DetailClip/View/Component/ChangeClipBottomSheetView.swift`
around lines 112 - 113, The Task block in ChangeClipBottomSheetView.swift uses
Task.sleep(for: .seconds(0.5)) which requires iOS 16+, breaking compilation for
iOS 15 deployment; replace that call with Task.sleep(nanoseconds: 500_000_000)
or wrap the entire Task { ... } block in an `@available`(iOS 16.0, *) guard so the
code only runs on iOS 16+, updating the Task invocation inside the async context
where Task.sleep is called to use the nanoseconds variant if you want iOS 15
compatibility.

await MainActor.run {
self.delegate?.completButtonTap()
completion()
}
}
}
)
Expand Down Expand Up @@ -174,6 +177,7 @@ extension ChangeClipBottomSheetView: UICollectionViewDelegateFlowLayout {
}
}

@MainActor
protocol ChangeClipBottomSheetViewDelegate: AnyObject {
func didSelectClip(selectClipId: Int)
func completButtonTap()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import UIKit
import SnapKit
import Then

@MainActor
protocol UserClipCollectionViewCellDelegate: AnyObject {
func addClipCellTapped()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import UIKit

// MARK: - Compositional Layout

@MainActor
enum CompositionalFactory {
static func create() -> UICollectionViewCompositionalLayout {
return UICollectionViewCompositionalLayout { (sectionNumber, _ environment) -> NSCollectionLayoutSection? in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,10 @@ private extension LinkWebViewController {
progressObservation = $0.observe(
\.estimatedProgress,
options: [.new]) { [weak self] object, _ in
let progress = Float(object.estimatedProgress)
self?.progressView.progress = progress
MainActor.assumeIsolated {
let progress = Float(object.estimatedProgress)
self?.progressView.progress = progress
}
Comment on lines 141 to +147
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 | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Swift MainActor.assumeIsolated documentation semantics main actor

💡 Result:

MainActor.assumeIsolated is a synchronous “trust me + verify” escape hatch:

  • What it promises (to the compiler): inside the closure, code is treated as running with @MainActor isolation, so you can make synchronous accesses that would normally require an await hop. Apple’s docs summarize it as: assume the current task is executing on the main actor’s serial executor, or stop program execution.[1]

  • What it checks (at runtime): the stdlib implementation fetches MainActor.shared’s serial executor and checks that the current task is already executing on that executor. If not, it crashes with fatalError("Incorrect actor executor assumption; Expected same executor as MainActor...").[2]

  • How it works (mechanically): after the executor check succeeds, it uses withoutActuallyEscaping + unsafeBitCast to erase the @MainActor annotation from the closure type and then calls it immediately (no hop, no suspension).[2]

  • Important nuance: the check is executor-based, not “main-thread”-based; if some other actor is configured to use the same serial executor as MainActor, the check can still succeed (this is explicitly documented in the stdlib comments).[2]

  • Intended use: only from synchronous code; the API is marked unavailable from async contexts with guidance to “await the call to the @MainActor closure directly”.[2]

Sources: Apple Developer Documentation entry for MainActor.assumeIsolated[1], Swift standard library implementation (stdlib/public/Concurrency/MainActor.swift).[2]


🌐 Web query:

NSKeyValueObservation callback main thread execution guarantee iOS

💡 Result:

NSKeyValueObservation (the Swift block-based KVO API) does not guarantee its callback runs on the main thread.

KVO notifications are delivered on the same thread where the observed property change happens—so if the property is mutated on a background thread, your changeHandler will run on that background thread as well. Apple explicitly states this for KVO: “If the change to the property occurs on a secondary thread, the observeValueForKeyPath:… code executes on that same thread.” [1]

If you need to update UI (main-thread-only work), you must hop to the main execution context yourself (e.g. DispatchQueue.main.async { … } / await MainActor.run { … }). Apple discusses using a “Receptionist” style approach to redirect work from a background execution context to the main one for UI updates. [2]

Sources

  1. Apple Docs – Cocoa Design Patterns (KVO threading note) [1]
  2. Apple Docs – Streamline Your App with Design Patterns (Receptionist pattern / execution context) [2]

Don't use MainActor.assumeIsolated in this observer.

MainActor.assumeIsolated asserts the callback is already on the main actor—it doesn't hop there. Since NSKeyValueObservation callbacks run on whatever thread the property mutation occurs, this will crash at runtime if estimatedProgress changes on a background thread. Use an explicit main-actor hop instead.

🔧 Safer main-actor hop
             progressObservation = $0.observe(
                 \.estimatedProgress,
-                 options: [.new]) { [weak self] object, _ in
-                     MainActor.assumeIsolated {
-                         let progress = Float(object.estimatedProgress)
-                         self?.progressView.progress = progress
-                     }
-                 }
+                options: [.new]) { [weak self] object, _ in
+                    let progress = Float(object.estimatedProgress)
+                    Task { `@MainActor` in
+                        self?.progressView.progress = progress
+                    }
+                }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TOASTER-iOS/Present/LinkWeb/ViewController/LinkWebViewController.swift`
around lines 141 - 147, The KVO callback for estimatedProgress (the observation
stored in progressObservation created via observe(\.estimatedProgress)) must not
call MainActor.assumeIsolated because the callback may run off the main actor;
replace it with an explicit main-actor hop: inside the observer closure, remove
MainActor.assumeIsolated and dispatch the UI update to the main actor (e.g.,
start a Task { `@MainActor` in ... } or DispatchQueue.main.async) then set
self?.progressView.progress using Float(object.estimatedProgress); keep the weak
self capture and the rest of the observation logic in LinkWebViewController
unchanged.

}
}
}
Expand Down
Loading
Loading