diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1dff0d --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Kotlin ### +.kotlin + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..a412a08 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,58 @@ +// build.gradle.kts + +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + kotlin("jvm") version "2.0.0" + id("org.jetbrains.compose") version "1.8.0" + kotlin("plugin.serialization") version "2.0.0" + // ⬇️ 이 줄을 추가해 주세요! + id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" +} +group = "com.example" +version = "1.0.0" + +repositories { + google() + mavenCentral() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") +} + +dependencies { + implementation(compose.desktop.currentOs) + implementation(compose.materialIconsExtended) + // JWT 라이브러리 + implementation("io.jsonwebtoken:jjwt-api:0.11.5") + implementation("io.jsonwebtoken:jjwt-impl:0.11.5") + implementation("io.jsonwebtoken:jjwt-jackson:0.11.5") + + // Ktor HTTP 클라이언트 + implementation("io.ktor:ktor-client-core:2.3.11") + implementation("io.ktor:ktor-client-cio:2.3.11") + implementation("io.ktor:ktor-client-logging:2.3.11") + // ⬇️ Ktor가 JSON을 처리할 수 있도록 도와주는 라이브러리 추가 + implementation("io.ktor:ktor-client-content-negotiation:2.3.11") + implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.11") + + // ⬇️ Kotlinx Serialization 라이브러리 추가 + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0") +} + +kotlin { + jvmToolchain(21) +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +compose.desktop { + application { + mainClass = "MainKt" + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "AnalyticsReportGenerator" // 앱 패키지 이름 변경 + } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..7fc6f1f --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..f228b23 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} +rootProject.name = "untitled" \ No newline at end of file diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt new file mode 100644 index 0000000..4eac89d --- /dev/null +++ b/src/main/kotlin/Main.kt @@ -0,0 +1,388 @@ +// Main.kt + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SignatureAlgorithm +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.request.* +import io.ktor.http.HttpHeaders +import io.ktor.serialization.kotlinx.json.* +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.awt.FileDialog +import java.awt.Frame +import java.io.File +import java.nio.file.Files +import java.nio.file.Paths +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.spec.PKCS8EncodedKeySpec +import java.text.NumberFormat +import java.time.DayOfWeek +import java.time.Instant +import java.time.LocalDate +import java.time.YearMonth +import java.time.format.DateTimeFormatter +import java.time.temporal.TemporalAdjusters +import java.util.* +import java.util.prefs.Preferences + +// --- 상수 정의 --- +private const val ISSUER_ID_KEY = "APP_STORE_ISSUER_ID" +private const val KEY_ID_KEY = "APP_STORE_KEY_ID" +private const val APP_ID_KEY = "APP_STORE_APP_ID" // Vendor Number 대신 App ID 사용 + +// --- 데이터 클래스 (JSON 파싱용) --- +@Serializable +data class AnalyticsMetric( + val data: List = emptyList() +) + +@Serializable +data class MetricData( + val dataPoints: DataPoints +) + +@Serializable +data class DataPoints( + val totals: Totals? = null +) + +@Serializable +data class Totals( + val count: Long? = null +) + +// --- 화면에 표시할 최종 분석 결과 데이터 클래스 --- +data class AnalyticsResult( + val installations: Long = 0, + val redownloads: Long = 0, + val updates: Long = 0 +) + +// --- API 클라이언트 --- +suspend fun fetchAnalyticsMetric( + client: HttpClient, + token: String, + appId: String, + metricType: String, + date: String +): Long { + try { + val response: AnalyticsMetric = client.get("https://api.appstoreconnect.apple.com/v1/apps/$appId/metrics") { + header(HttpHeaders.Authorization, "Bearer $token") + parameter("granularity", "DAY") + parameter("filter[metricType]", metricType) + parameter("filter[days]", date) + }.body() + return response.data.firstOrNull()?.dataPoints?.totals?.count ?: 0L + } catch (e: Exception) { + println("Error fetching metric $metricType: ${e.message}") + throw e // 오류를 상위로 전파하여 처리 + } +} + + +@Composable +fun SimpleDatePicker( + initialDate: LocalDate, + onDateSelected: (LocalDate) -> Unit, + onDismissRequest: () -> Unit +) { + var displayedYearMonth by remember { mutableStateOf(YearMonth.from(initialDate)) } + Dialog(onDismissRequest = onDismissRequest) { + Surface( + shape = MaterialTheme.shapes.medium, + modifier = Modifier.padding(16.dp).widthIn(max = 320.dp), + elevation = 8.dp + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Button(onClick = { displayedYearMonth = displayedYearMonth.minusMonths(1) }) { Text("<") } + Text( + text = displayedYearMonth.format(DateTimeFormatter.ofPattern("yyyy년 MM월")), + style = MaterialTheme.typography.h6 + ) + Button(onClick = { displayedYearMonth = displayedYearMonth.plusMonths(1) }) { Text(">") } + } + Spacer(Modifier.height(16.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + listOf("일", "월", "화", "수", "목", "금", "토").forEach { day -> + Text( + text = day, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold + ) + } + } + Spacer(Modifier.height(8.dp)) + val daysInMonth = displayedYearMonth.lengthOfMonth() + val firstDayOfMonth = displayedYearMonth.atDay(1) + val firstDayOfWeek = firstDayOfMonth.dayOfWeek.value % 7 + LazyVerticalGrid( + columns = androidx.compose.foundation.lazy.grid.GridCells.Fixed(7), + contentPadding = PaddingValues(vertical = 4.dp) + ) { + items(firstDayOfWeek) { Box(Modifier.size(40.dp)) } + items(daysInMonth) { day -> + val date = displayedYearMonth.atDay(day + 1) + val isSelected = date == initialDate + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(if (isSelected) MaterialTheme.colors.primary else Color.Transparent) + .clickable { onDateSelected(date) }, + contentAlignment = Alignment.Center + ) { + Text( + text = "${day + 1}", + color = if (isSelected) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onSurface + ) + } + } + } + } + } + } +} + +@Composable +fun App( + filePath: String, + onFilePathChange: (String) -> Unit +) { + var error by remember { mutableStateOf(null) } + val prefs = remember { Preferences.userRoot().node("com.example.analyticsreportgenerator") } + + var issuerId by remember { mutableStateOf(prefs.get(ISSUER_ID_KEY, "")) } + var keyId by remember { mutableStateOf(prefs.get(KEY_ID_KEY, "")) } + var appId by remember { mutableStateOf(prefs.get(APP_ID_KEY, "")) } // Vendor Number 대신 App ID + + var statusMessage by remember { mutableStateOf("분석할 앱의 정보를 입력하고 날짜를 선택하세요.") } + var analyticsResult by remember { mutableStateOf(null) } + + val scope = rememberCoroutineScope() + val dailyFormat = DateTimeFormatter.ISO_LOCAL_DATE + var reportDate by remember { mutableStateOf(LocalDate.now().minusDays(2)) } + var showDatePicker by remember { mutableStateOf(false) } + + val client = remember { + HttpClient(CIO) { + install(Logging) { + logger = object : Logger { + override fun log(message: String) { + println("===== KTOR API LOG =====\n$message\n========================") + } + } + level = LogLevel.ALL + } + // JSON 파싱을 위한 ContentNegotiation 플러그인 설치 + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true // API 응답에 모르는 필드가 있어도 무시 + }) + } + } + } + DisposableEffect(Unit) { onDispose { client.close() } } + val privateKey: PrivateKey? by remember(filePath) { derivedStateOf { if (filePath.isNotBlank()) { try { error = null; loadPrivateKey(filePath) } catch (e: Exception) { error = "개인키 파일 로드 실패: ${e.message}"; null } } else { null } } } + fun isReady(): Boolean { + if (privateKey == null) { error = "개인키 파일(.p8)을 선택하세요."; return false } + if (issuerId.isBlank() || keyId.isBlank()) { error = "Issuer ID와 Key ID를 입력하세요."; return false } + if (appId.isBlank()) { error = "분석을 위해 App ID를 입력하세요."; return false } + error = null; return true + } + + if (showDatePicker) { + SimpleDatePicker( + initialDate = reportDate, + onDismissRequest = { showDatePicker = false }, + onDateSelected = { date -> + reportDate = date + showDatePicker = false + } + ) + } + + Row(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.width(350.dp).fillMaxHeight().verticalScroll(rememberScrollState()).padding(16.dp)) { + Text("Issuer ID"); TextField(value = issuerId, onValueChange = {issuerId = it; prefs.put(ISSUER_ID_KEY, it)}, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp)) + Text("Key ID"); TextField(value = keyId, onValueChange = {keyId = it; prefs.put(KEY_ID_KEY, it)}, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp)) + Text("App ID (앱 고유 ID)"); TextField(value = appId, onValueChange = {appId = it; prefs.put(APP_ID_KEY, it)}, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp)) + + Text("개인키(.p8) 파일 (버튼을 클릭하여 파일 선택)") + Row(verticalAlignment = Alignment.CenterVertically) { + TextField(value = filePath, onValueChange = onFilePathChange, singleLine = true, modifier = Modifier.weight(1f), readOnly = true) + Spacer(Modifier.width(8.dp)) + Button(onClick = { + val dialog = FileDialog(Frame(), "개인키(.p8) 파일을 선택하세요", FileDialog.LOAD).apply { + setFile("*.p8") + isVisible = true + } + if (dialog.directory != null && dialog.file != null) { + onFilePathChange(dialog.directory + dialog.file) + } + }) { Text("찾기...") } + } + + Divider(modifier = Modifier.padding(vertical = 16.dp)) + + Text("조회 날짜 (YYYY-MM-DD)") + Box(modifier = Modifier.clickable { showDatePicker = true }) { + OutlinedTextField( + value = reportDate.format(dailyFormat), onValueChange = { }, readOnly = true, modifier = Modifier.fillMaxWidth(), + trailingIcon = { Icon(imageVector = Icons.Default.DateRange, contentDescription = "날짜 선택") }, + enabled = false, + colors = TextFieldDefaults.outlinedTextFieldColors( + disabledTextColor = LocalContentColor.current.copy(LocalContentAlpha.current), + disabledBorderColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled), + disabledLabelColor = MaterialTheme.colors.onSurface.copy(ContentAlpha.medium), + disabledTrailingIconColor = MaterialTheme.colors.onSurface.copy(alpha = TextFieldDefaults.IconOpacity) + ) + ) + } + Spacer(Modifier.height(16.dp)) + Button( + onClick = { + if (!isReady()) return@Button + scope.launch { + try { + analyticsResult = null + val dateString = reportDate.format(dailyFormat) + statusMessage = "[$dateString] 앱 분석 데이터 로딩 중..." + error = null + val reportToken = createJWT(privateKey!!, issuerId, keyId, listOf("GET:/v1/apps/$appId/metrics")) + + // 3가지 메트릭을 동시에 요청 (병렬 처리) + coroutineScope { + val installsDeferred = async { fetchAnalyticsMetric(client, reportToken, appId, "INSTALLATIONS", dateString) } + val redownloadsDeferred = async { fetchAnalyticsMetric(client, reportToken, appId, "REDOWNLOADS", dateString) } + val updatesDeferred = async { fetchAnalyticsMetric(client, reportToken, appId, "UPDATES", dateString) } + + analyticsResult = AnalyticsResult( + installations = installsDeferred.await(), + redownloads = redownloadsDeferred.await(), + updates = updatesDeferred.await() + ) + } + statusMessage = "[$dateString] 분석 완료" + } catch (e: Exception) { + println("===== 작업 실패 ====="); e.printStackTrace(); error = "작업 오류: ${e.message}"; statusMessage = "오류: ${e.message}" + } + } + }, + modifier = Modifier.fillMaxWidth(), enabled = privateKey != null + ) { Text("앱 분석 통계 조회") } + Spacer(Modifier.height(16.dp)) + if (error != null) { Text(text = error ?: "", color = Color.Red, modifier = Modifier.padding(top = 8.dp)) } + } + Divider(modifier = Modifier.fillMaxHeight().width(1.dp)) + + ReportView( + modifier = Modifier.weight(1f).fillMaxHeight(), + result = analyticsResult, + statusMessage = statusMessage + ) + } +} + +@Composable +fun ReportView( + modifier: Modifier = Modifier, + result: AnalyticsResult?, + statusMessage: String +) { + Column(modifier.padding(16.dp)) { + Text( + text = statusMessage, + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f), + modifier = Modifier.padding(bottom = 16.dp) + ) + if (result != null) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + AnalyticsCard("최초 설치 (Installations)", result.installations, MaterialTheme.colors.primary) + AnalyticsCard("재다운로드 (Re-downloads)", result.redownloads, Color(0xFF00897B)) + AnalyticsCard("업데이트 (Updates)", result.updates, Color(0xFF5E35B1)) + } + } else { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("표시할 데이터가 없습니다.", color = Color.Gray) + } + } + } +} + +@Composable +fun AnalyticsCard(title: String, value: Long, color: Color) { + Card(elevation = 4.dp, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text(title, style = MaterialTheme.typography.h6) + Spacer(Modifier.height(8.dp)) + Text( + text = NumberFormat.getInstance().format(value), + style = MaterialTheme.typography.h4.copy(fontWeight = FontWeight.Bold), + color = color + ) + } + } +} + +fun loadPrivateKey(path: String): PrivateKey { + val keyBytes = Files.readAllBytes(Paths.get(path)); val keyPem = String(keyBytes).replace("-----BEGIN PRIVATE KEY-----", "").replace("-----END PRIVATE KEY-----", "").replace("\\s".toRegex(), ""); val decoded = Base64.getDecoder().decode(keyPem); val keySpec = PKCS8EncodedKeySpec(decoded); val kf = KeyFactory.getInstance("EC"); return kf.generatePrivate(keySpec) +} + +fun createJWT(privateKey: PrivateKey, issuerId: String, keyId: String, scope: List?): String { + val now = Instant.now(); val expire = now.plusSeconds(20 * 60); val builder = Jwts.builder().setHeaderParam("alg", "ES256").setHeaderParam("kid", keyId).setHeaderParam("typ", "JWT").setIssuer(issuerId).setAudience("appstoreconnect-v1").setIssuedAt(Date.from(now)).setExpiration(Date.from(expire)); if (!scope.isNullOrEmpty()) { builder.claim("scope", scope) }; return builder.signWith(privateKey, SignatureAlgorithm.ES256).compact() +} + +fun main() = application { + var filePath by remember { mutableStateOf("") } + Window(onCloseRequest = ::exitApplication, title = "App Analytics 리포트 생성기") { + MaterialTheme { + App( + filePath = filePath, + onFilePathChange = { filePath = it } + ) + } + } +} \ No newline at end of file