Skip to content
Merged
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
29 changes: 28 additions & 1 deletion .github/workflows/tuist-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ concurrency:

jobs:
build-test:
name: Build & Test (UMCApp / Tuist)
name: Build & Test (UMCApp + watchOS / Tuist)
runs-on: macos-26

defaults:
Expand Down Expand Up @@ -120,6 +120,33 @@ jobs:
- name: Build & Test (make test)
run: make test

# ์•„๋ž˜ watchOS ์Šคํ…๋“ค์„ ๋ณ„๋„ ์žก์œผ๋กœ ๋นผ์ง€ ์•Š๋Š” ์ด์œ (์ด์Šˆ #1216):
# ์‹œํฌ๋ฆฟ ๋ณต์› ยท mise/Tuist ์„ค์น˜ ยท SPM ์บ์‹œ ยท tuist generate ๋ฅผ ํ†ต์งธ๋กœ ํ•œ ๋ฒˆ ๋”
# ๋Œ๋ ค์•ผ ํ•ด์„œ ๋Ÿฌ๋„ˆ ์‹œ๊ฐ„์ด ๋‘ ๋ฐฐ๊ฐ€ ๋œ๋‹ค. ๊ฐ™์€ ์žก์— ์ด์–ด ๋ถ™์ด๋ฉด ์ƒ์„ฑ๋œ ์›Œํฌ์ŠคํŽ˜์ด์Šค๋ฅผ
# ๊ทธ๋Œ€๋กœ ์žฌ์‚ฌ์šฉํ•œ๋‹ค.
- name: Check watchOS simulator runtime
run: |
# generic/platform=watchOS Simulator ๋นŒ๋“œ๋Š” ๋Ÿฐํƒ€์ž„ ์„ค์น˜ ์—†์ด๋„ ํ†ต๊ณผํ•˜๋Š” ๊ฒฝ์šฐ๊ฐ€
# ๋งŽ๋‹ค. ๊ทธ๋ž˜์„œ ์—ฌ๊ธฐ์„œ ์‹คํŒจ์‹œํ‚ค์ง€ ์•Š๊ณ  ๊ฒฝ๊ณ ๋งŒ ๋‚จ๊ธด๋‹ค โ€” ๋Ÿฌ๋„ˆ ์ด๋ฏธ์ง€์—์„œ ๋Ÿฐํƒ€์ž„์ด
# ๋น ์กŒ์„ ๋•Œ ๋’ค ์Šคํ…์ด ๊นจ์ง€๋ฉด ์ด ๋กœ๊ทธ๊ฐ€ ์›์ธ์„ ๋ฐ”๋กœ ๊ฐ€๋ฆฌํ‚จ๋‹ค.
if xcrun simctl list runtimes | grep -qi watchos; then
xcrun simctl list runtimes | grep -i watchos
else
echo "::warning::watchOS simulator runtime not found. Attempting download..."
xcodebuild -downloadPlatform watchOS || \
echo "::warning::watchOS runtime download failed. Continuing with generic destination."
fi

- name: Build watchOS app (make build-watch)
run: make build-watch

# UMCApp ์Šคํ‚ด์˜ test action ์—๋Š” UMCAppTests ๋งŒ ๋“ค์–ด ์žˆ์–ด์„œ ์œ„ `make test` ๋กœ๋Š”
# Core ๋ชจ๋“ˆ ํ…Œ์ŠคํŠธ๊ฐ€ ๋Œ์ง€ ์•Š๋Š”๋‹ค. ์›Œ์น˜ ํšŒ๊ท€๋ฅผ ์žก์œผ๋ ค๋ฉด ์Šคํ‚ด์„ ๋ช…์‹œํ•ด์•ผ ํ•œ๋‹ค.
# destination ์€ Makefile ๊ธฐ๋ณธ๊ฐ’(iOS Simulator)์„ ๊ทธ๋Œ€๋กœ ์“ด๋‹ค โ€” CoreWatchConnectivity
# ๋Š” iOS/watchOS ๋ฉ€ํ‹ฐํ”Œ๋žซํผ ํƒ€๊ฒŸ์ด๋ผ iOS ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ์—์„œ ๋กœ์ง ํ…Œ์ŠคํŠธ๊ฐ€ ๋ˆ๋‹ค.
- name: Test CoreWatchConnectivity
run: make test SCHEME=CoreWatchConnectivity

notify:
name: Notify Discord
needs: build-test
Expand Down
145 changes: 89 additions & 56 deletions UMCApp/Core/WatchConnectivity/Sources/WatchSessionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,15 @@ public final class WatchSessionCoordinator: NSObject, WCSessionDelegate {
@ObservationIgnored
private let userInfoContinuation: AsyncStream<WatchMessage>.Continuation

private var session: WCSession { .default }
/// `WCSession` ์€ ์‹ฑ๊ธ€ํ„ด์ด๋ผ ์ง์ ‘ ๋งŒ๋“ค ์ˆ˜ ์—†๋‹ค. ์ƒํƒœ ์ „์ด๋ฅผ ํ…Œ์ŠคํŠธ์—์„œ ์žฌํ˜„ํ•˜๋ ค๋ฉด
/// ์ด ํ‘œ๋ฉด๋งŒ ๋Œ€์—ญ์œผ๋กœ ๋ฐ”๊ฟ€ ์ˆ˜ ์žˆ์–ด์•ผ ํ•ด์„œ ``WatchSessionProviding`` ์„ ๊ฑฐ์นœ๋‹ค.
@ObservationIgnored
private let session: any WatchSessionProviding

// MARK: - Init

public override init() {
public init(session: any WatchSessionProviding = WCSession.default) {
self.session = session
let (stream, continuation) = AsyncStream<WatchMessage>.makeStream()
userInfoStream = stream
userInfoContinuation = continuation
Expand All @@ -73,9 +77,9 @@ public final class WatchSessionCoordinator: NSObject, WCSessionDelegate {

/// WCSession ์„ ํ™œ์„ฑํ™”ํ•œ๋‹ค. ์•ฑ ์‹œ์ž‘ ์‹œ ํ•œ ๋ฒˆ ํ˜ธ์ถœํ•œ๋‹ค.
public func activate() {
guard WCSession.isSupported() else { return }
session.delegate = self
session.activate()
guard session.isSupported else { return }
session.attach(delegate: self)
session.startActivation()
}

/// ์ตœ์‹  ์Šค๋ƒ…์ƒท์„ ์š”์ฒญํ•œ๋‹ค.
Expand Down Expand Up @@ -125,7 +129,7 @@ public final class WatchSessionCoordinator: NSObject, WCSessionDelegate {
try requireActivated()
do {
let payload = try WatchEnvelope.encode(WatchMessage.sessionState(state))
try session.updateApplicationContext(payload)
try session.apply(applicationContext: payload)
} catch let error as WatchConnectivityError {
throw error
} catch {
Expand All @@ -144,7 +148,7 @@ public final class WatchSessionCoordinator: NSObject, WCSessionDelegate {
throw WatchConnectivityError.unsupportedChannel(message)
}
try requireActivated()
session.transferUserInfo(try WatchEnvelope.encode(message))
session.transfer(userInfo: try WatchEnvelope.encode(message))
}

/// ์•„์ง ์ „์†ก๋˜์ง€ ์•Š์€ ํ ํ•ญ๋ชฉ.
Expand All @@ -153,7 +157,7 @@ public final class WatchSessionCoordinator: NSObject, WCSessionDelegate {
/// ์ถ”์ ํ•˜์ง€ ๋ชปํ•ด, SwiftUI ๊ฐ€ ๋ฐ”์ธ๋”ฉํ•ด๋„ ํ•œ ๋ฒˆ ๊ทธ๋ฆฐ ๋’ค ์˜์›ํžˆ ๊ฐฑ์‹ ๋˜์ง€ ์•Š๋Š”๋‹ค. ํ˜ธ์ถœ ์‹œ์ ์˜
/// ์Šค๋ƒ…์ƒท์ด๋ฏ€๋กœ ํ™”๋ฉด ์บก์…˜์€ ``purgeExpiredQueue(now:)`` ์˜ ๋ฐ˜ํ™˜๊ฐ’์ด๋‚˜ ํƒ€์ด๋จธ๋กœ ๊ฐฑ์‹ ํ•œ๋‹ค.
public var pendingMessages: [WatchMessage] {
session.outstandingUserInfoTransfers.compactMap {
session.outstandingTransfers.compactMap {
try? WatchEnvelope.decode(WatchMessage.self, from: $0.userInfo)
}
}
Expand All @@ -166,7 +170,7 @@ public final class WatchSessionCoordinator: NSObject, WCSessionDelegate {
@discardableResult
public func purgeExpiredQueue(now: Date = Date()) -> [WatchAttendanceRequest] {
var purged: [WatchAttendanceRequest] = []
for transfer in session.outstandingUserInfoTransfers {
for transfer in session.outstandingTransfers {
guard
let message = try? WatchEnvelope.decode(
WatchMessage.self, from: transfer.userInfo
Expand Down Expand Up @@ -199,10 +203,10 @@ public final class WatchSessionCoordinator: NSObject, WCSessionDelegate {
// MARK: - Private

private func requireActivated() throws {
guard WCSession.isSupported() else {
guard session.isSupported else {
throw WatchConnectivityError.notSupported
}
guard session.activationState == .activated else {
guard session.isActivated else {
throw WatchConnectivityError.sessionNotActivated
}
}
Expand All @@ -217,63 +221,65 @@ public final class WatchSessionCoordinator: NSObject, WCSessionDelegate {
let payload = try WatchEnvelope.encode(message)

return try await withCheckedThrowingContinuation { continuation in
session.sendMessage(payload) { raw in
session.send(payload) { raw in
do {
let reply = try WatchEnvelope.decode(WatchReply.self, from: raw)
continuation.resume(returning: reply)
} catch {
continuation.resume(throwing: error)
}
} errorHandler: { error in
} onError: { error in
continuation.resume(throwing: WatchConnectivityError.from(error))
}
}
}

// MARK: - WCSessionDelegate
// MARK: - Internal

public nonisolated func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
// WCSession ์€ Sendable ์ด ์•„๋‹ˆ๋‹ค. hop ํ•˜๊ธฐ ์ „์— ๊ฐ’๋งŒ ์ฝ์–ด ๋‘”๋‹ค.
let activated = activationState == .activated
let reachable = session.isReachable
// `receivedApplicationContext` ๋Š” ํ™œ์„ฑํ™”๊ฐ€ ๋๋‚œ ๋’ค์—์•ผ ์ฑ„์›Œ์ง„๋‹ค. ํ™œ์„ฑํ™”๋Š” ๋น„๋™๊ธฐ๋ผ
// `activate()` ์งํ›„์— ์ฝ์œผ๋ฉด ๋นˆ ๋”•์…”๋„ˆ๋ฆฌ๋ฅผ ๋ฐ›์•„ ์‹œ๋”ฉ์ด ์กฐ์šฉํžˆ ๋ฌด์‚ฐ๋œ๋‹ค.
let context: [String: Any] = activated ? session.receivedApplicationContext : [:]
let seeded = try? WatchEnvelope.decode(WatchMessage.self, from: context)
Task { @MainActor in
self.isActivated = activated
self.isReachable = reachable
// ๋ธ๋ฆฌ๊ฒŒ์ดํŠธ ์ฝœ๋ฐฑ์ด ์ด๋ฏธ ๋” ์ตœ์‹  ์ปจํ…์ŠคํŠธ๋ฅผ ๋„ฃ์—ˆ๋‹ค๋ฉด ๋ฎ์–ด์“ฐ์ง€ ์•Š๋Š”๋‹ค.
if case .sessionState(let state)? = seeded, self.receivedState == nil {
self.receivedState = state
}
/// ํ™œ์„ฑํ™” ์ฝœ๋ฐฑ์ด ์‹ค์–ด ์˜จ ๊ฐ’์„ ์ƒํƒœ์— ๋ฐ˜์˜ํ•œ๋‹ค.
///
/// ๋ธ๋ฆฌ๊ฒŒ์ดํŠธ ์ฝœ๋ฐฑ์—์„œ ๊ฐˆ๋ผ๋‚ธ ์ด์œ ๋Š” `WCSession` ์ธ์Šคํ„ด์Šค๋ฅผ ํ…Œ์ŠคํŠธ์—์„œ ๋งŒ๋“ค ์ˆ˜ ์—†๊ธฐ
/// ๋•Œ๋ฌธ์ด๋‹ค. ์ฝœ๋ฐฑ์€ ๊ฐ’๋งŒ ๋ฝ‘์•„ ์ด ํ•จ์ˆ˜๋กœ hop ํ•˜๊ณ , ํ…Œ์ŠคํŠธ๋Š” ๊ฐ’์„ ์ง์ ‘ ๋„ฃ๋Š”๋‹ค.
func applyActivation(_ activated: Bool, reachable: Bool, seeded: WatchMessage?) {
isActivated = activated
isReachable = activated && reachable
// ๋ธ๋ฆฌ๊ฒŒ์ดํŠธ ์ฝœ๋ฐฑ์ด ์ด๋ฏธ ๋” ์ตœ์‹  ์ปจํ…์ŠคํŠธ๋ฅผ ๋„ฃ์—ˆ๋‹ค๋ฉด ๋ฎ์–ด์“ฐ์ง€ ์•Š๋Š”๋‹ค.
if case .sessionState(let state)? = seeded, receivedState == nil {
receivedState = state
}
}

public nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
let reachable = session.isReachable
Task { @MainActor in
self.isReachable = reachable
/// ํ™œ์„ฑํ™” ์ „ ๋„๋‹ฌ์„ฑ์€ ์˜๋ฏธ๊ฐ€ ์—†๋‹ค โ€” ใ€Œ๋„๋‹ฌ ๊ฐ€๋Šฅใ€์ธ๋ฐ ์ „์†ก์ด `.sessionNotActivated`
/// ๋กœ ์‹คํŒจํ•˜๋Š” ์ƒํƒœ๋ฅผ ๋งŒ๋“ค์ง€ ์•Š๋Š”๋‹ค.
func applyReachability(_ reachable: Bool) {
isReachable = isActivated && reachable
}

func applyReceivedContext(_ message: WatchMessage?) {
guard case .sessionState(let state)? = message else { return }
receivedState = state
}

nonisolated func ingest(userInfo: [String: Any]) {
guard let message = try? WatchEnvelope.decode(WatchMessage.self, from: userInfo) else {
return
}
// hop ํ•˜์ง€ ์•Š๋Š”๋‹ค โ€” continuation ์€ Sendable ์ด๊ณ  yield ์ˆœ์„œ๋ฅผ ๊ทธ๋Œ€๋กœ ๋ณด์กดํ•œ๋‹ค.
userInfoContinuation.yield(message)
}

/// `replyHandler` ๋Š” ์†ก์‹ ์ž์˜ ํƒ€์ž„์•„์›ƒ(7012) ์•ˆ์— **์ •ํ™•ํžˆ ํ•œ ๋ฒˆ** ํ˜ธ์ถœ๋ผ์•ผ ํ•œ๋‹ค.
/// early return ๊ณผ `Task` ๊ฐ€ ์ƒํ˜ธ ๋ฐฐํƒ€์ ์ธ ํ˜•ํƒœ๋ผ ใ€Œํ•œ ๋ฒˆ๋งŒ ํ˜ธ์ถœใ€ ์žฅ์น˜๊ฐ€ ๋”ฐ๋กœ ํ•„์š” ์—†๋‹ค.
public nonisolated func session(
_ session: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void
nonisolated func handle(
request: [String: Any],
reply replyHandler: @escaping ([String: Any]) -> Void
) {
nonisolated(unsafe) let reply = replyHandler

// ์‹คํŒจ๋Š” hop ์—†์ด ์ฆ‰์‹œ ์‘๋‹ตํ•œ๋‹ค.
let decoded: WatchMessage
do {
decoded = try WatchEnvelope.decode(WatchMessage.self, from: message)
decoded = try WatchEnvelope.decode(WatchMessage.self, from: request)
} catch WatchConnectivityError.unsupportedSchemaVersion(let version) {
// ์†์ƒ์ด ์•„๋‹ˆ๋ผ ์ƒ๋Œ€๊ฐ€ ๋” ์ƒˆ๋กœ์šด ์Šคํ‚ค๋งˆ๋ฅผ ์“ด๋‹ค๋Š” ์‹ ํ˜ธ๋‹ค. ใ€Œ์†์ƒใ€์œผ๋กœ ๋ญ‰๊ฐœ๋ฉด
// ์ƒ๋Œ€๋Š” ์—…๋ฐ์ดํŠธ๊ฐ€ ํ•„์š”ํ•˜๋‹ค๋Š” ์‚ฌ์‹ค์„ ์•Œ ์ˆ˜ ์—†๋‹ค.
Expand All @@ -299,39 +305,66 @@ public final class WatchSessionCoordinator: NSObject, WCSessionDelegate {
}
}

// MARK: - WCSessionDelegate

public nonisolated func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
// WCSession ์€ Sendable ์ด ์•„๋‹ˆ๋‹ค. hop ํ•˜๊ธฐ ์ „์— ๊ฐ’๋งŒ ์ฝ์–ด ๋‘”๋‹ค.
// `.activated` ๋ผ๋„ ์—๋Ÿฌ๊ฐ€ ํ•จ๊ป˜ ์˜ค๋ฉด ์ „์†ก์€ ์ „๋ถ€ ์‹คํŒจํ•œ๋‹ค โ€” ์—๋Ÿฌ๋ฅผ ๋ฌด์‹œํ•˜๋ฉด
// ํ™”๋ฉด์€ ใ€Œ์—ฐ๊ฒฐ๋จใ€์ธ๋ฐ ๋ชจ๋“  ์š”์ฒญ์ด ์กฐ์šฉํžˆ ์ฃฝ๋Š”๋‹ค.
let activated = activationState == .activated && error == nil
// `receivedApplicationContext` ๋Š” ํ™œ์„ฑํ™”๊ฐ€ ๋๋‚œ ๋’ค์—์•ผ ์ฑ„์›Œ์ง„๋‹ค. ํ™œ์„ฑํ™”๋Š” ๋น„๋™๊ธฐ๋ผ
// `activate()` ์งํ›„์— ์ฝ์œผ๋ฉด ๋นˆ ๋”•์…”๋„ˆ๋ฆฌ๋ฅผ ๋ฐ›์•„ ์‹œ๋”ฉ์ด ์กฐ์šฉํžˆ ๋ฌด์‚ฐ๋œ๋‹ค.
let context: [String: Any] = activated ? session.receivedApplicationContext : [:]
let seeded = try? WatchEnvelope.decode(WatchMessage.self, from: context)
let reachable = session.isReachable
Task { @MainActor in
self.applyActivation(activated, reachable: reachable, seeded: seeded)
}
}

public nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
let reachable = session.isReachable
Task { @MainActor in
self.applyReachability(reachable)
}
}

public nonisolated func session(
_ session: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void
) {
handle(request: message, reply: replyHandler)
}

public nonisolated func session(
_ session: WCSession,
didReceiveApplicationContext applicationContext: [String: Any]
) {
guard
let message = try? WatchEnvelope.decode(
WatchMessage.self, from: applicationContext
),
case .sessionState(let state) = message
else { return }
// `[String: Any]` ๋Š” Sendable ์ด ์•„๋‹ˆ๋‹ค. hop ์ „์— ๋ด‰ํˆฌ๋ฅผ ๋ฒ—๊ฒจ ๊ฐ’๋งŒ ๋„˜๊ธด๋‹ค.
let message = try? WatchEnvelope.decode(WatchMessage.self, from: applicationContext)
Task { @MainActor in
self.receivedState = state
self.applyReceivedContext(message)
}
}

public nonisolated func session(
_ session: WCSession,
didReceiveUserInfo userInfo: [String: Any]
) {
guard let message = try? WatchEnvelope.decode(WatchMessage.self, from: userInfo) else {
return
}
// hop ํ•˜์ง€ ์•Š๋Š”๋‹ค โ€” continuation ์€ Sendable ์ด๊ณ  yield ์ˆœ์„œ๋ฅผ ๊ทธ๋Œ€๋กœ ๋ณด์กดํ•œ๋‹ค.
userInfoContinuation.yield(message)
ingest(userInfo: userInfo)
}

#if os(iOS)
/// ์‚ฌ์šฉ์ž๊ฐ€ ๋‹ค๋ฅธ ์›Œ์น˜๋กœ ๊ฐˆ์•„ํƒ€๋Š” ์ค‘์ด๋‹ค. ์ด ์„ธ์…˜์œผ๋กœ๋Š” ๋” ๋ณด๋‚ผ ์ˆ˜ ์—†์œผ๋ฏ€๋กœ ์ƒํƒœ๋ฅผ ๋‚ด๋ฆฐ๋‹ค โ€”
/// ๋‚จ๊ฒจ ๋‘๋ฉด ํ™”๋ฉด์€ ใ€Œ์—ฐ๊ฒฐ๋จใ€์ธ๋ฐ ์ „์†ก๋งŒ ์กฐ์šฉํžˆ ์‹คํŒจํ•œ๋‹ค.
public nonisolated func sessionDidBecomeInactive(_ session: WCSession) {
Task { @MainActor in
self.isActivated = false
self.isReachable = false
self.applyActivation(false, reachable: false, seeded: nil)
}
}

Expand Down
Loading
Loading