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
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,16 @@ These may proceed after inspection when they do not change architecture meaning:
- Do not claim architecture work is complete without checking the diff scope.
- Do not spend time on unrelated generated project or lockfile churn. Keep generated workspace/project and `Package.resolved` changes out of source control unless they are part of an explicitly approved dependency-lock policy.

## Git and commit rules

- Commit messages must start with a short prefix used by recent local commits, such as `feat`, `fix`, `refactor`, `chore`, `test`, `docs`, `ui`, or `rollback`.
- Write commit message prose in Korean.
- Keep implementation names such as `ToastPresenter`, `toastHost`, `MainView`, `DevLogPresentation`, file paths, commands, branch names, and commit hashes in their original form.
- Do not translate implementation names into Korean unless the user explicitly asks for a user-facing Korean label.
- Do not write a commit message body.
- When checking recent commit-message style, do not infer local commit style from GitHub merge or squash-merge subjects such as `[#123] ... (#456)`.
- For squash-merge commits, inspect the commit body and use the individual bullet commit messages as the style reference.

## Canonical project rules

- DevLog-specific working rules belong in this repository, not in global agent memory.
Expand Down
19 changes: 18 additions & 1 deletion Application/DevLogApp/Sources/App/DevLogApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ struct DevLogApp: App {
@Environment(\.diContainer) var container: DIContainer
@Environment(\.scenePhase) var scenePhase
@State private var windowEvent = TodoEditorWindowEvent()
@State private var syncDate = Date()

init() {
AppAssembler().assemble(AppDIContainer.shared)
Expand All @@ -38,7 +39,23 @@ struct DevLogApp: App {
.autocorrectionDisabled()
.onChange(of: scenePhase) { _, phase in
guard phase == .background else { return }
container.resolve(WidgetSyncEventBus.self).publish(.syncRequested)
let now = Date()
let bus = container.resolve(WidgetSyncEventBus.self)

// 위젯 갱신은 앱 실행 시 로그인 세션 흐름에서 한 번 요청된다. (WidgetSessionSyncHandler.swift:47)
// Todo 변경 성공 시에는 즉시 fetch하지 않고 WidgetSyncEventBus에 갱신 요청만 남긴다.
// 따라서 같은 날의 백그라운드 진입은 저장된 요청이 있을 때만 기존 syncRequested 흐름을 실행한다.
// 앱이 실행된 상태로 날짜가 넘어간 경우에는 Today widget의 분류 기준일 자체가 바뀌므로,
// 콘텐츠 변경 여부와 관계없이 기존 syncRequested 흐름을 즉시 허용한다.
guard Calendar.current.isDate(syncDate, inSameDayAs: now) else {
syncDate = now
_ = bus.confirmRequest()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

confirmRequest()에 @discardableResult 속성이 추가되면, 불필요한 _ = 대입 식을 제거하고 더 깔끔하게 호출할 수 있습니다.

Suggested change
_ = bus.confirmRequest()
bus.confirmRequest()

bus.publish(.syncRequested)
return
}

guard bus.confirmRequest() else { return }
bus.publish(.syncRequested)
}
}
WindowGroup(id: TodoEditorWindowValue.sceneId, for: TodoEditorWindowValue.self) { value in
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//
// WidgetConfigurationProvider.swift
// DevLogData
//
// Created by opfic on 6/28/26.
//

public protocol WidgetConfigurationProvider {
func currentWidgetKinds() async throws -> Set<String>
}
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ final class TodoRepositoryImpl: TodoRepository {
private func upsertTodo(_ todoRequest: TodoRequest) async throws {
do {
try await todoService.upsertTodo(request: todoRequest)
widgetSyncEventBus.publish(.syncRequested)
widgetSyncEventBus.request()
} catch {
throw error.toDomain()
}
Expand All @@ -152,7 +152,7 @@ final class TodoRepositoryImpl: TodoRepository {
func deleteTodo(_ todoId: String) async throws {
do {
try await todoService.deleteTodo(todoId: todoId)
widgetSyncEventBus.publish(.syncRequested)
widgetSyncEventBus.request()
todoMutationEventBus.publish(.deleted(todoId))
} catch {
throw error.toDomain()
Expand All @@ -162,7 +162,7 @@ final class TodoRepositoryImpl: TodoRepository {
func undoDeleteTodo(_ todoId: String) async throws {
do {
try await todoService.undoDeleteTodo(todoId: todoId)
widgetSyncEventBus.publish(.syncRequested)
widgetSyncEventBus.request()
todoMutationEventBus.publish(.restored(todoId))
} catch {
throw error.toDomain()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@ import Combine

public protocol WidgetSyncEventBus {
func publish(_ event: WidgetSyncEvent)
func request()
func confirmRequest() -> Bool

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

confirmRequest() 메서드는 반환값을 무시하고 호출하는 경우가 많으므로, 호출부에서 _ =를 사용하지 않고도 경고 없이 호출할 수 있도록 @discardableResult 속성을 추가하는 것이 좋습니다.

Suggested change
func confirmRequest() -> Bool
@discardableResult
func confirmRequest() -> Bool

func observe() -> AnyPublisher<WidgetSyncEvent, Never>
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,16 @@ import DevLogDomain
@testable import DevLogData

struct TodoRepositoryImplTests {
@Test("Todo 변경 성공 시 위젯 동기화와 mutation 이벤트를 발행한다")
func todo_변경_성공_시_위젯_동기화와_mutation_이벤트를_발행한다() async throws {
@Test("Todo 변경 성공 시 위젯 동기화 요청과 mutation 이벤트를 발행한다")
func todo_변경_성공_시_위젯_동기화_요청과_mutation_이벤트를_발행한다() async throws {
let fixture = makeFixture()
let todo = makeTodo()

try await fixture.repository.upsertTodo(todo)
try await fixture.repository.deleteTodo(todo.id)
try await fixture.repository.undoDeleteTodo(todo.id)

let events = fixture.widgetSyncEventBus.events
#expect(events == [.syncRequested, .syncRequested, .syncRequested])
#expect(fixture.widgetSyncEventBus.requestCallCount == 3)

let mutationEvents = fixture.todoMutationEventBus.publishedEvents()
#expect(mutationEvents == [.updated(todo.id), .deleted(todo.id), .restored(todo.id)])
Expand Down Expand Up @@ -55,8 +54,7 @@ struct TodoRepositoryImplTests {
#expect(error as? TodoRepositoryImplTestsError == .serviceFailed)
}

let syncEvents = fixture.widgetSyncEventBus.events
#expect(syncEvents.isEmpty)
#expect(fixture.widgetSyncEventBus.requestCallCount == 0)

let mutationEvents = fixture.todoMutationEventBus.publishedEvents()
#expect(mutationEvents.isEmpty)
Expand Down Expand Up @@ -177,14 +175,20 @@ private final class TodoRepositoryMemoryCacheStoreSpy: MemoryCacheStore {
}

private final class WidgetSyncEventBusSpy: WidgetSyncEventBus {
private(set) var events = [WidgetSyncEvent]()
private(set) var requestCallCount = 0

func observe() -> AnyPublisher<WidgetSyncEvent, Never> {
Empty().eraseToAnyPublisher()
func publish(_ event: WidgetSyncEvent) { }

func request() {
requestCallCount += 1
}

func publish(_ event: WidgetSyncEvent) {
events.append(event)
func confirmRequest() -> Bool {
false
}

func observe() -> AnyPublisher<WidgetSyncEvent, Never> {
Empty().eraseToAnyPublisher()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ private final class WidgetSyncEventBusSpy: WidgetSyncEventBus {
events.append(event)
}

func request() { }

func confirmRequest() -> Bool {
false
}

func observe() -> AnyPublisher<WidgetSyncEvent, Never> {
Empty().eraseToAnyPublisher()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ public final class WidgetAssembler: Assembler {
container.register(WidgetSyncEventBus.self) {
WidgetSyncEventBusImpl()
}
container.register(WidgetConfigurationProvider.self) {
WidgetConfigurationProviderImpl()
}
container.register(WidgetSharedDefaultsStore.self) {
WidgetSharedDefaultsStore()
}
Expand All @@ -41,7 +44,8 @@ public final class WidgetAssembler: Assembler {
WidgetSyncEventHandler(
eventBus: container.resolve(WidgetSyncEventBus.self),
repository: container.resolve(WidgetTodoSnapshotRepository.self),
snapshotUpdater: container.resolve(WidgetSnapshotUpdater.self)
snapshotUpdater: container.resolve(WidgetSnapshotUpdater.self),
configurationProvider: container.resolve(WidgetConfigurationProvider.self)
)
}
container.register(WidgetSessionSyncHandler.self) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// WidgetConfigurationProviderImpl.swift
// DevLogWidget
//
// Created by opfic on 6/28/26.
//

import WidgetKit
import DevLogData

public final class WidgetConfigurationProviderImpl: WidgetConfigurationProvider {
public init() { }

public func currentWidgetKinds() async throws -> Set<String> {
try await withCheckedThrowingContinuation { continuation in
WidgetCenter.shared.getCurrentConfigurations { result in
continuation.resume(
with: result.map { configurations in
Set(configurations.map(\.kind))
}
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,35 @@
//

import Combine
import Foundation
import DevLogData

public final class WidgetSyncEventBusImpl: WidgetSyncEventBus {
private let subject = PassthroughSubject<WidgetSyncEvent, Never>()
private let lock = NSLock()
private var isRequested = false

public init() { }

public func publish(_ event: WidgetSyncEvent) {
subject.send(event)
}
Comment on lines +14 to 21

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

WidgetSyncEventBusImpl에서 PassthroughSubject는 스레드 안전(Thread-safe)하지 않으므로, 여러 스레드에서 동시에 publish(_:)를 호출할 경우 크래시가 발생할 수 있습니다.

또한, 이벤트 버스의 특성상 이벤트를 구독하는 측(Subscriber)에서 동기적으로 다시 이벤트를 발행하거나 상태를 확인할 가능성이 있습니다. 이때 일반 NSLock을 사용하면 동일한 스레드에서 락을 중복 취득하려다 데드락(Deadlock)이 발생할 수 있습니다.

따라서 NSLock 대신 NSRecursiveLock을 사용하고, publish(_:) 메서도 락으로 보호하여 스레드 안전성을 확보하는 것을 권장합니다.

    private let lock = NSRecursiveLock()
    private var isRequested = false

    public init() { }

    public func publish(_ event: WidgetSyncEvent) {
        lock.lock()
        defer { lock.unlock() }
        subject.send(event)
    }


public func request() {
lock.lock()
defer { lock.unlock() }
isRequested = true
}

public func confirmRequest() -> Bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

프로토콜 정의에 맞춰 구현체에도 @discardableResult 속성을 추가하여 반환값을 생략할 수 있도록 합니다.

Suggested change
public func confirmRequest() -> Bool {
@discardableResult
public func confirmRequest() -> Bool {

lock.lock()
defer { lock.unlock() }

guard isRequested else { return false }
isRequested = false
return true
}

public func observe() -> AnyPublisher<WidgetSyncEvent, Never> {
subject.eraseToAnyPublisher()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,25 @@ import Combine
import Foundation
import DevLogCore
import DevLogData
import DevLogWidgetCore

public final class WidgetSyncEventHandler {
private let repository: WidgetTodoSnapshotRepository
private let snapshotUpdater: WidgetSnapshotUpdater
private let configurationProvider: WidgetConfigurationProvider
private let pageSize = 100
private let logger = Logger(category: "WidgetSyncEventHandler")
private var cancellables = Set<AnyCancellable>()

public init(
eventBus: WidgetSyncEventBus,
repository: WidgetTodoSnapshotRepository,
snapshotUpdater: WidgetSnapshotUpdater
snapshotUpdater: WidgetSnapshotUpdater,
configurationProvider: WidgetConfigurationProvider
) {
self.repository = repository
self.snapshotUpdater = snapshotUpdater
self.configurationProvider = configurationProvider

eventBus.observe()
.sink { [weak self] event in
Expand All @@ -40,13 +44,32 @@ private extension WidgetSyncEventHandler {
Task { [weak self] in
guard let self else { return }
let now = Date()
async let todaySnapshot: Void = updateTodayWidgetSnapshot(now: now)
async let heatmapSnapshot: Void = updateHeatmapWidgetSnapshot(now: now)
_ = await (todaySnapshot, heatmapSnapshot)
let targets = await enabledSnapshotTargets()
await withTaskGroup(of: Void.self) { group in
if targets.contains(.today) {
group.addTask { await self.updateTodayWidgetSnapshot(now: now) }
}
if targets.contains(.heatmap) {
group.addTask { await self.updateHeatmapWidgetSnapshot(now: now) }
}
}
}
}
}

func enabledSnapshotTargets() async -> Set<WidgetSnapshotTarget> {
do {
let kinds = try await configurationProvider.currentWidgetKinds()
return WidgetSnapshotTarget.targets(for: kinds)
} catch {
logger.error(
"Failed to fetch current widget configurations.",
error: error
)
return Set(WidgetSnapshotTarget.allCases)
}
}

func updateTodayWidgetSnapshot(now: Date) async {
do {
async let todosWithDueDate = fetchTodayTodos(
Expand Down Expand Up @@ -143,3 +166,19 @@ private extension WidgetSyncEventHandler {
)
}
}

private enum WidgetSnapshotTarget: CaseIterable {
case today
case heatmap

static func targets(for kinds: Set<String>) -> Set<Self> {
var targets = Set<Self>()
if kinds.contains(WidgetKind.todayTodo) {
targets.insert(.today)
}
if kinds.contains(WidgetKind.heatmap) {
targets.insert(.heatmap)
}
return targets
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ private final class WidgetSyncEventBusSpy: WidgetSyncEventBus {
events.append(event)
}

func request() { }

func confirmRequest() -> Bool {
false
}

func observe() -> AnyPublisher<WidgetSyncEvent, Never> {
Empty().eraseToAnyPublisher()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,16 @@ struct WidgetSyncEventBusTests {
#expect(receivedEvents == [.syncRequested])
_ = cancellable
}

@Test("WidgetSyncEventBus는 요청 여부를 한 번만 소비한다")
func widgetSyncEventBus는_요청_여부를_한_번만_소비한다() {
let bus = WidgetSyncEventBusImpl()

#expect(bus.confirmRequest() == false)

bus.request()

#expect(bus.confirmRequest())
#expect(bus.confirmRequest() == false)
}
}
Loading
Loading