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
21 changes: 18 additions & 3 deletions Sources/PokeTokenBar/Core/CompanionModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ enum AppLanguage: String, Codable, Sendable, CaseIterable {
var label: String {
switch self { case .ko: return "한국어"; case .en: return "English"; case .ja: return "日本語" }
}

/// 신규 설치 기본 언어 — 시스템 선호 언어에서 유추(글로벌 출시: 한국어 강제 금지).
/// ko/ja 만 매칭, 그 외 전부 영어(fallback-of-fallback). 기존 사용자는 저장된 언어를 그대로 쓴다.
static var systemDefault: AppLanguage {
switch Locale.preferredLanguages.first?.prefix(2).lowercased() {
case "ko": return .ko
case "ja": return .ja
default: return .en
}
}
}

/// 희귀도 — PokéAPI capture_rate / is_legendary 로 판정.
Expand Down Expand Up @@ -157,7 +167,8 @@ struct MonState: Codable, Sendable {
var totalForms: Int
var isShiny = false // 부화 시 확정, 진화해도 유지
var nature: PokemonNature? // 부화 시 확정 (구버전 저장은 nil)
var currentID: Int { pathIDs[min(stageIndex, pathIDs.count - 1)] }
// pathIDs 가 비면(손상된 상태 파일) baseID 로 폴백 — 렌더마다 읽히므로 out-of-bounds 크래시 방지.
var currentID: Int { pathIDs.isEmpty ? baseID : pathIDs[min(stageIndex, pathIDs.count - 1)] }

init(baseID: Int, pathIDs: [Int], stageIndex: Int, usedAtStage: Int,
rarity: Rarity, totalForms: Int, isShiny: Bool = false, nature: PokemonNature? = nil) {
Expand All @@ -176,6 +187,10 @@ struct MonState: Codable, Sendable {
let c = try decoder.container(keyedBy: CodingKeys.self)
baseID = try c.decode(Int.self, forKey: .baseID)
pathIDs = try c.decode([Int].self, forKey: .pathIDs)
// 빈 pathIDs 는 손상 상태 → 디코드 실패시켜 전체 CompanionState 가 기본(알)로 폴백되게 한다.
guard !pathIDs.isEmpty else {
throw DecodingError.dataCorruptedError(forKey: .pathIDs, in: c, debugDescription: "empty pathIDs")
}
stageIndex = try c.decode(Int.self, forKey: .stageIndex)
usedAtStage = try c.decode(Int.self, forKey: .usedAtStage)
rarity = try c.decode(Rarity.self, forKey: .rarity)
Expand Down Expand Up @@ -238,7 +253,7 @@ struct CompanionState: Codable, Sendable {
var dex: [DexEntry] = []
// 소유한 (base,final) 쌍 — 분기 다양성용
var collectedFinals: Set<String> = []
var language: AppLanguage = .ko
var language: AppLanguage = .systemDefault // 신규 설치 = 시스템 로케일

init() {}

Expand All @@ -254,7 +269,7 @@ struct CompanionState: Codable, Sendable {
active = try c.decodeIfPresent(MonState.self, forKey: .active)
dex = try c.decodeIfPresent([DexEntry].self, forKey: .dex) ?? []
collectedFinals = try c.decodeIfPresent(Set<String>.self, forKey: .collectedFinals) ?? []
language = try c.decodeIfPresent(AppLanguage.self, forKey: .language) ?? .ko
language = try c.decodeIfPresent(AppLanguage.self, forKey: .language) ?? .systemDefault
}
}

Expand Down
5 changes: 5 additions & 0 deletions Sources/PokeTokenBar/Core/Localization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ struct L {
var notifShinyHatchTitle: String { t("✨ 색이 다른 포켓몬!", "✨ Shiny Pokémon!", "✨ 色違いポケモン!") }
func notifShinyHatchBody(_ name: String) -> String { t("색이 다른 \(name)이(가) 태어났어요! (1/64)", "A shiny \(name) hatched! (1 in 64)", "色違いの \(name) が生まれました!(1/64)") }
var eggImminent: String { t("곧 부화해요!", "About to hatch!", "もうすぐ孵化!") }
/// 첫 실행(아직 토큰 적립 0) 안내 — "왜 아무 일도 안 일어나지"를 방지.
var eggFirstRunHint: String {
t("로컬 Claude·Codex·Gemini 로그의 사용량으로 자라요. 약 5M 토큰을 쓰면 알이 부화해요.",
"Grows from your local Claude/Codex/Gemini usage. Your egg hatches after ~5M tokens.",
"ローカルの Claude・Codex・Gemini の使用量で育ちます。約5Mトークンでタマゴが孵化します。") }
var notifEvolveTitle: String { t("✨ 진화!", "✨ Evolved!", "✨ 進化!") }
func notifEvolveBody(_ name: String) -> String { t("\(name)(으)로 진화했어요!", "Evolved into \(name)!", "\(name) に進化しました!") }
var notifGraduateTitle: String { t("🎓 졸업!", "🎓 Graduated!", "🎓 卒業!") }
Expand Down
11 changes: 8 additions & 3 deletions Sources/PokeTokenBar/Core/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ final class UsageStore {

/// 앱 언어 미러(알림 현지화용). 단일 소스는 CompanionStore.language —
/// 설정 변경/기동 시 동기화한다.
var localizationLanguage: AppLanguage = .ko
var localizationLanguage: AppLanguage = .systemDefault // companion.language 로 재시드 전까지의 기본(실행순서 무관 안전)

private let providers: [any UsageProvider]
private let limitsProvider: any ClaudeLimitsProviding
Expand Down Expand Up @@ -241,7 +241,8 @@ final class UsageStore {
Task { @MainActor in self?.resumePolling() }
}

requestNotificationAuthorization()
// 알림 권한은 기동 즉시 묻지 않는다 — 앱을 이해하기 전 콜드 프롬프트는 거부율이 높고
// 거부 시 재요청 경로가 없다. 팝오버 첫 오픈(사용자 의도)에 requestNotificationAuthorizationIfNeeded 로 1회 요청.
if autoRefresh { Task { await refresh() } }
}

Expand Down Expand Up @@ -471,8 +472,12 @@ final class UsageStore {

// MARK: 한도 알림 (ClaudeBar 임계값 패턴)

private func requestNotificationAuthorization() {
private var notifAuthRequested = false
/// 팝오버 첫 오픈 등 사용자 의도 시점에 1회만 알림 권한 요청(멱등).
func requestNotificationAuthorizationIfNeeded() {
guard !notifAuthRequested else { return }
guard Bundle.main.bundleIdentifier != nil, Bundle.main.bundlePath.hasSuffix(".app") else { return }
notifAuthRequested = true
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
}

Expand Down
1 change: 1 addition & 0 deletions Sources/PokeTokenBar/PokeTokenBarApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
NSApp.activate(ignoringOtherApps: true)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
popover.contentViewController?.view.window?.makeKeyAndOrderFront(nil)
store.requestNotificationAuthorizationIfNeeded() // 알림 권한은 사용자가 앱을 처음 열 때 요청
Task { await updater.check() } // 팝오버 열 때 재확인(내부 minInterval 디바운스)
}
}
Expand Down
10 changes: 8 additions & 2 deletions Sources/PokeTokenBar/UI/CompanionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ struct CompanionHeader: View {
Text(store.displayName).font(.callout.weight(.semibold))
if store.currentIsShiny { Text("✨").font(.system(size: 11)) }
if let r = store.rarity {
Text(r.rawValue.uppercased()).font(.system(size: 8, weight: .bold))
Text(store.l.rarityLabel(r).uppercased()).font(.system(size: 8, weight: .bold))
.padding(.horizontal, 5).padding(.vertical, 1)
.background(rarityColor(r)).foregroundStyle(.white)
.clipShape(Capsule())
Expand All @@ -168,6 +168,12 @@ struct CompanionHeader: View {
ProgressView(value: store.eggProgress).controlSize(.small).tint(.orange)
Text(store.l.eggToHatch(TokenFormatter.compact(store.eggTokensToHatch)))
.font(.caption2).foregroundStyle(.tertiary)
// 첫 실행(적립 0) — 정적 알 앞에서 "고장났나" 오해 방지용 한 줄 안내
if !store.eggStarted {
Text(store.l.eggFirstRunHint)
.font(.caption2).foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
Text(statusLine).font(.caption2).foregroundStyle(.secondary)
}
Expand Down Expand Up @@ -282,7 +288,7 @@ struct CollectionView: View {
ForEach(store.dexEntriesSorted) { entry in
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(entry.rarity.rawValue.uppercased())
Text(store.l.rarityLabel(entry.rarity).uppercased())
.font(.system(size: 8, weight: .bold))
.padding(.horizontal, 5).padding(.vertical, 1)
.background(rarityColor(entry.rarity)).foregroundStyle(.white)
Expand Down
23 changes: 23 additions & 0 deletions Tests/PokeTokenBarTests/CompanionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,29 @@ final class CompanionIdentityTests: XCTestCase {
XCTAssertEqual(round.active?.isShiny, false)
}

/// [출시 안전] 손상된 상태 파일: active.pathIDs 가 비면 디코드가 실패해야 한다
/// (→ load() 가 기본 알 상태로 폴백 → currentID out-of-bounds 크래시 방지).
func testEmptyPathIDsRejectedOnDecode() {
let corrupt = """
{"installBaselineSet":true,"eggUsage":0,"lastDate":"d1",
"active":{"baseID":1,"pathIDs":[],"stageIndex":0,"usedAtStage":0,"rarity":"common","totalForms":3}}
"""
XCTAssertThrowsError(try JSONDecoder().decode(CompanionState.self, from: Data(corrupt.utf8)),
"빈 pathIDs 는 디코드 거부돼야 한다")
}

/// currentID 는 pathIDs 가 비어도(방어) baseID 로 폴백 — 크래시 없음.
func testCurrentIDFallsBackToBaseWhenPathEmpty() {
let m = MonState(baseID: 42, pathIDs: [], stageIndex: 0, usedAtStage: 0, rarity: .common, totalForms: 1)
XCTAssertEqual(m.currentID, 42)
}

/// 신규 설치 기본 언어는 시스템 로케일에서 유추 — 유효한 케이스이고 크래시 없음(한국어 강제 아님).
func testSystemDefaultLanguageResolves() {
XCTAssertTrue(AppLanguage.allCases.contains(AppLanguage.systemDefault))
XCTAssertEqual(CompanionState().language, AppLanguage.systemDefault)
}

/// 부화/진화가 연출 트리거(celebrationSeq)를 올리고, consume 후 비워지는지.
func testCelebrationFiresOnHatchAndEvolve() async {
let s = store(linear3, seed: 9)
Expand Down