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
1 change: 1 addition & 0 deletions Sources/PokeTokenBar/PokeTokenBarApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import QuartzCore
import SwiftUI

@main
@MainActor
struct PokeTokenBarApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate

Expand Down
2 changes: 2 additions & 0 deletions Sources/PokeTokenBar/UI/BagView.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import SwiftUI

/// 가방(인벤토리) — 소유 아이템 카드 + 사용. 빈 상태는 움직이는 잠만보(컬렉션의 피카츄 패턴).
@MainActor
struct BagView: View {
let store: CompanionStore
let nav: PopoverNavigation
Expand Down Expand Up @@ -36,6 +37,7 @@ struct BagView: View {
/// 아이템 1장 — 아이콘·이름·개수·설명 + 인라인 확인 사용.
/// 확인은 인라인(버튼 morph) — .sheet/.alert 금지: transient 팝오버가 닫힐 때 고아 시트가
/// 이후 클릭을 먹통내는 기존 결함(PopoverView 주석) 회피.
@MainActor
private struct ItemCard: View {
let store: CompanionStore
let nav: PopoverNavigation
Expand Down
11 changes: 11 additions & 0 deletions Sources/PokeTokenBar/UI/CompanionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func rarityColor(_ r: Rarity?) -> Color {
let rarityDisplayOrder: [Rarity] = [.legendary, .rare, .uncommon, .common]

/// 아이템 아이콘 — 실제 스프라이트(런타임 로드+캐시) 우선, 로딩 전/미제공/실패 시 이모지 폴백.
@MainActor
struct ItemIconView: View {
let kind: ItemKind
var size: CGFloat = 30
Expand Down Expand Up @@ -81,6 +82,7 @@ struct SpriteSubject: Equatable {

/// 스프라이트 1개(런타임 로드 + 캐시). 없으면 알 글리프. bob 으로 가벼운 상하 움직임.
/// animated=true 면 Gen-V GIF 프레임을 순환(미지원/오프라인이면 정적+bob 으로 폴백).
@MainActor
struct SpriteView: View {
let speciesID: Int?
var size: CGFloat = 84
Expand Down Expand Up @@ -235,6 +237,7 @@ struct SpriteView: View {
/// 폭 제한 없는 HStack 은 팝오버 콘텐츠 폭(332pt)을 넘고, **넘친 자식이 부모 VStack 폭을 부풀려
/// 팝오버 전체가 좌우로 잘린다**(진화줄뿐 아니라 탭바·합계까지). `maxWidth` 를 주면 그 폭 안에서
/// 가로 스크롤한다 — 썸네일 크기는 유지하고, 가장자리 페이드 + 셰브론으로 스크롤 가능함을 알린다.
@MainActor
struct EvoLineView: View {
let nodes: [EvoLineItem]
let mysteryLabel: String
Expand Down Expand Up @@ -435,6 +438,7 @@ struct EvoLineView: View {
}

/// 팝오버 상단 — 현재 포켓몬 + 진화 진행 + 부화/진화 연출.
@MainActor
struct CompanionHeader: View {
let store: CompanionStore
// 연출 상태 — 부화/진화 순간 흰 플래시 + 스프링 스케일(본가 진화 신 오마주)
Expand Down Expand Up @@ -653,6 +657,7 @@ struct CompanionHeader: View {
/// (solid 채움 대신 링+체크 — green/orange 위 흰 텍스트 대비 문제 회피 + 라이트/다크 양쪽 가독.
/// 텍스트는 .primary 라 모드 자동 적응, 색 정체성은 점·링·체크로 유지 → 엔트리 배지와 안 어긋남.)
/// 0이면 흐리게(필터 불가).
@MainActor
struct RarityTally: View {
let label: String
let count: Int
Expand Down Expand Up @@ -680,6 +685,7 @@ struct RarityTally: View {

/// 포획 로그 요약 헤더 — 총 개체 수 + 희귀도별 개체 수 캡슐.
/// 개수 단위가 개체(store.dexCount)라 종 단위인 도감 헤더와 공유하지 않는다.
@MainActor
struct DexSummaryHeader: View {
let store: CompanionStore
let selected: Rarity? // nil = 필터 없음(전체)
Expand Down Expand Up @@ -715,6 +721,7 @@ struct DexSummaryHeader: View {
/// - **로그**: 개체 1마리 = 1행. 같은 라인이 여러 행으로 나오는 게 정상 — 성격·획득 시각처럼
/// 개체에 딸린 정보는 여기에만 있다.
/// 상위 탭(PopoverTab)은 그대로 4개 — 세그먼트 폭(332/2)이 넉넉해 탭바를 늘릴 필요가 없다.
@MainActor
struct CollectionView: View {
let store: CompanionStore
let navigation: PopoverNavigation
Expand Down Expand Up @@ -797,6 +804,7 @@ struct CollectionView: View {

/// 도감 하단의 대표 설정 액션. 문구는 툴팁·접근성에 유지하되 시각적으로는 아이콘만 써서,
/// 긴 en/es 문구가 선택한 종의 이름·희귀도를 밀어내지 않게 한다.
@MainActor
struct RepresentativeFooterButton: View {
let localization: L
let isRepresentative: Bool
Expand Down Expand Up @@ -825,6 +833,7 @@ struct RepresentativeFooterButton: View {
/// 우회(고정 높이 + maxHeight)가 아니라 회피로 피한다. 페이지 크기가 고정이라 모든 칸이 항상
/// 렌더되므로 지연 격자(LazyVGrid)도 필요 없다 — 평범한 VStack/HStack 으로 동기 렌더한다.
/// 미보유 종은 아예 그리지 않는다(물음표·실루엣 칸 없음).
@MainActor
private struct DexGridView: View {
let store: CompanionStore
@State private var selectedRarity: Rarity?
Expand Down Expand Up @@ -956,6 +965,7 @@ private struct DexGridView: View {

/// 도감 한 칸 — 도감 번호 + 스프라이트 + 종 이름. 종 정보만 담는다(성격·획득 횟수는 로그의 몫).
/// 정적 스프라이트만 쓴다(animated 생략) — 한 페이지 24칸을 GIF 로 동시 재생하면 CPU 가 안 된다.
@MainActor
private struct DexSpeciesCell: View {
let store: CompanionStore
let species: CompanionStore.DexSpecies
Expand Down Expand Up @@ -1075,6 +1085,7 @@ private struct DexSpeciesCell: View {

/// 포획 로그 한 항목 — 희귀도·성격 헤더 + 진화 체인 스프라이트(각 밑에 종 이름) + 잡은 시각.
/// 체인 각 종의 이름은 저장분이 있으면 body 에서 즉시(플래시 없음), 없으면(구버전) .task 로 조회 후 백필.
@MainActor
private struct DexEntryRow: View {
let store: CompanionStore
let entry: DexEntry
Expand Down
2 changes: 2 additions & 0 deletions Sources/PokeTokenBar/UI/FloatingPetPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ final class PetHostingView: NSHostingView<AnyView> {
@objc func handleHide(_ sender: Any?) { onHide?() }
}

@MainActor
struct FloatingPetView: View {
static let frameFloor: TimeInterval = 0.4
var animated: Bool = true
Expand Down Expand Up @@ -448,6 +449,7 @@ struct FloatingPetView: View {
}

/// Transient limit-alert bubble. Width is capped so copy wraps instead of clipping the panel.
@MainActor
private struct SpeechBubbleView: View {
let alert: UsageStore.LimitAlert
let l: L
Expand Down
2 changes: 2 additions & 0 deletions Sources/PokeTokenBar/UI/PopoverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ final class PopoverNavigation {
}
}

@MainActor
struct PopoverView: View {
@Environment(UsageStore.self) private var store
@Environment(CompanionStore.self) private var companion
Expand Down Expand Up @@ -623,6 +624,7 @@ struct PopoverView: View {
/// "Curso/r"·"Code/x" 처럼 **단어 중간에서** 접고 탭 바가 2~3줄이 된다.
/// 가로 스크롤 + `lineLimit(1)`/`fixedSize` 로 각 탭이 항상 자연 폭 한 줄을 유지한다.
/// (`Spacer()` 는 가로 ScrollView 안에서 무한 확장하므로 쓰지 않는다.)
@MainActor
struct ProviderTabBar: View {
let snapshots: [ProviderSnapshot]
let selectedID: String?
Expand Down
1 change: 1 addition & 0 deletions Sources/PokeTokenBar/UI/SettingsView.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import SwiftUI
import UniformTypeIdentifiers

@MainActor
struct SettingsView: View {
@Environment(UsageStore.self) private var store
@Environment(CompanionStore.self) private var companion
Expand Down
3 changes: 3 additions & 0 deletions Sources/PokeTokenBar/UI/ShopView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import SwiftUI
/// 상점 — 사용한 토큰(재화 = usedSinceInstall − spentTokens)으로 아이템 구매(이상한 사탕·민트).
/// 인라인 확인(버튼 morph) — .sheet/.alert 금지(BagView 주석과 동일: transient 팝오버가 닫힐 때
/// 고아 시트가 이후 클릭을 먹통내는 결함 회피).
@MainActor
struct ShopView: View {
let store: CompanionStore
let nav: PopoverNavigation
Expand Down Expand Up @@ -46,6 +47,7 @@ struct ShopView: View {

/// 상점 아이템 1장 — 아이콘·이름·설명(사탕 XP / 민트 "성격 랜덤 변경")·보유수 + 가격/구매(인라인 확인).
/// kind 별 store.canBuy(kind)/buy(kind) 로 일반화 — 판매 목록은 store.purchasableItems.
@MainActor
private struct ShopItemCard: View {
let store: CompanionStore
let kind: ItemKind
Expand Down Expand Up @@ -126,6 +128,7 @@ private struct ShopItemCard: View {
///
/// 등급 알의 시각 구분은 **카드의 등급 배지**로만 한다 — 알 스프라이트는 한 장뿐이고, 메뉴바·플로팅 펫은
/// 기존 알 그대로 둔다(새 에셋 없이 구분이 서는 최소 범위).
@MainActor
private struct EggCard: View {
let store: CompanionStore
let nav: PopoverNavigation
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Darwin
@preconcurrency import Darwin
import Foundation
import XCTest
@testable import PokeTokenBar
Expand Down
40 changes: 40 additions & 0 deletions Tests/PokeTokenBarTests/SwiftUIIsolationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import XCTest
@testable import PokeTokenBar

final class SwiftUIIsolationTests: XCTestCase {
func testEverySwiftUIViewAndAppIsMainActorIsolated() throws {
let sources = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // PokeTokenBarTests
.deletingLastPathComponent() // Tests
.deletingLastPathComponent() // repo root
.appendingPathComponent("Sources/PokeTokenBar")
let enumerator = try XCTUnwrap(FileManager.default.enumerator(
at: sources, includingPropertiesForKeys: nil))
var offenders: [String] = []

for case let url as URL in enumerator where url.pathExtension == "swift" {
let lines = try String(contentsOf: url, encoding: .utf8)
.split(separator: "\n", omittingEmptySubsequences: false)
.map(String.init)
for index in lines.indices {
let declaration = lines[index].trimmingCharacters(in: .whitespaces)
let isStruct = declaration.hasPrefix("struct ")
|| declaration.hasPrefix("private struct ")
guard isStruct,
declaration.contains(": View") || declaration.contains(": App") else { continue }

let previous = lines[..<index].reversed()
.map { $0.trimmingCharacters(in: .whitespaces) }
.first { !$0.isEmpty }
if previous != "@MainActor" {
offenders.append("\(url.lastPathComponent):\(index + 1)")
}
}
}

XCTAssertTrue(offenders.isEmpty, """
Swift 6.3 treats SwiftUI helper properties/closures as nonisolated unless the View/App boundary is explicit.
Add @MainActor immediately above: \(offenders.joined(separator: ", "))
""")
}
}
11 changes: 11 additions & 0 deletions docs/reference/defect-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,17 @@ read_when:
**opt-in(`POKETOKENBAR_RUN_LARGE_PERF=1` / `scripts/perf-codex-large-rollout.sh`)이라 CI 는 돌리지 않는다.**
자동으로 막히지 않으므로 이 부류를 건드리면 직접 돌린다. (#184)

## 빌드·도구체인

- **SwiftUI `View`/`App` 경계는 `@MainActor` 를 명시한다.** Swift 6.3 은 `body` 밖의 `@ViewBuilder` helper·
동기 클로저를 nonisolated 로 검사해, `@MainActor` `@Observable` store 접근이 수십 개의 오류로 연쇄된다.
개별 프로퍼티에 `MainActor.assumeIsolated` 를 흩뿌리지 말고 UI 타입 선언 한 곳에 격리를 둔다.
`SwiftUIIsolationTests.testEverySwiftUIViewAndAppIsMainActorIsolated` 가 새 View/App 선언 누락을 소스 스캔으로 막는다.
- **coverage profile producer와 consumer는 같은 LLVM toolchain이어야 한다.** Homebrew Swift 6.3 이 만든
`default.profdata` 를 구형 Xcode의 `xcrun llvm-cov` 로 읽으면 `unsupported instrumentation profile format
version` 으로 테스트 성공 뒤 게이트만 실패한다. `test-gate.sh` 는 현재 `swift` 실경로 옆의 `llvm-cov` 를
우선하고, sibling이 없는 Apple toolchain에서만 `xcrun --find llvm-cov` 로 폴백한다.

## 자격증명·Keychain

- **앱 소유 keychain 항목 금지.** 앱이 만든 keychain 항목은 코드서명(cdhash)이 바뀔 때마다(로컬 재빌드·
Expand Down
11 changes: 10 additions & 1 deletion scripts/test-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,18 @@ if [[ -z "$PROF" || -z "$BIN" ]]; then
exit 1
fi

# Coverage profile format is tied to the Swift/LLVM toolchain that produced it. Homebrew Swift 6.x
# profiles are newer than the llvm-cov bundled with older Xcode, so prefer the sibling llvm-cov.
SWIFT_TOOL_DIR=$(dirname "$(realpath "$(command -v swift)")")
if [[ -x "$SWIFT_TOOL_DIR/llvm-cov" ]]; then
LLVM_COV="$SWIFT_TOOL_DIR/llvm-cov"
else
LLVM_COV=$(xcrun --find llvm-cov)
fi

echo
echo "▶ 로직 코어 커버리지 (임계값 ${THRESHOLD}%)"
REPORT=$(xcrun llvm-cov report "$BIN" -instr-profile="$PROF" "${LOGIC_CORE[@]}" 2>/dev/null)
REPORT=$("$LLVM_COV" report "$BIN" -instr-profile="$PROF" "${LOGIC_CORE[@]}" 2>/dev/null)
echo "$REPORT"

# TOTAL 행의 라인 커버리지(%) 추출 — 컬럼: ... Lines MissedLines Cover(=$10)
Expand Down