diff --git a/src/en/kmanga/AndroidManifest.xml b/src/en/kmanga/AndroidManifest.xml new file mode 100644 index 000000000..add22611a --- /dev/null +++ b/src/en/kmanga/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/src/en/kmanga/build.gradle b/src/en/kmanga/build.gradle new file mode 100644 index 000000000..db9216258 --- /dev/null +++ b/src/en/kmanga/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'K Manga' + extClass = '.KManga' + extVersionCode = 1 + isNsfw = false +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/kmanga/res/mipmap-hdpi/ic_launcher.png b/src/en/kmanga/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..4e7de12f2 Binary files /dev/null and b/src/en/kmanga/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/kmanga/res/mipmap-mdpi/ic_launcher.png b/src/en/kmanga/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..5e92c8d90 Binary files /dev/null and b/src/en/kmanga/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/kmanga/res/mipmap-xhdpi/ic_launcher.png b/src/en/kmanga/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..a7fdeca4c Binary files /dev/null and b/src/en/kmanga/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/kmanga/res/mipmap-xxhdpi/ic_launcher.png b/src/en/kmanga/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..4f1c1d421 Binary files /dev/null and b/src/en/kmanga/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/kmanga/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/kmanga/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..b19f584d0 Binary files /dev/null and b/src/en/kmanga/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/Dto.kt b/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/Dto.kt new file mode 100644 index 000000000..79355e8c3 --- /dev/null +++ b/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/Dto.kt @@ -0,0 +1,64 @@ +package eu.kanade.tachiyomi.extension.en.kmanga + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +class RankingApiResponse( + @SerialName("ranking_title_list") val rankingTitleList: List, +) + +@Serializable +class RankingTitleId( + val id: Int, +) + +@Serializable +class TitleListResponse( + @SerialName("title_list") val titleList: List, +) + +@Serializable +class TitleDetail( + @SerialName("title_id") val titleId: Int, + @SerialName("title_name") val titleName: String, + @SerialName("thumbnail_image_url") val thumbnailImageUrl: String? = null, + @SerialName("banner_image_url") val bannerImageUrl: String? = null, +) + +@Serializable +class BirthdayCookie(val value: String, val expires: Long) + +@Serializable +class EpisodeListResponse( + @SerialName("episode_list") val episodeList: List, +) + +@Serializable +class Episode( + @SerialName("episode_id") val episodeId: Int, + @SerialName("episode_name") val episodeName: String, + @SerialName("start_time") val startTime: String, + val point: Int, + @SerialName("title_id") val titleId: Int, + val badge: Int, + @SerialName("rental_finish_time") val rentalFinishTime: String? = null, +) + +@Serializable +class ViewerApiResponse( + @SerialName("page_list") val pageList: List, + @SerialName("scramble_seed") val scrambleSeed: Long, +) + +@Serializable +class SearchApiResponse( + @SerialName("title_list") val titleList: List, +) + +@Serializable +class SearchTitleDetail( + @SerialName("title_id") val titleId: Int, + @SerialName("title_name") val titleName: String, + @SerialName("thumbnail_image_url") val thumbnailImageUrl: String? = null, +) diff --git a/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/ImageInterceptor.kt b/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/ImageInterceptor.kt new file mode 100644 index 000000000..ab4257950 --- /dev/null +++ b/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/ImageInterceptor.kt @@ -0,0 +1,95 @@ +package eu.kanade.tachiyomi.extension.en.kmanga + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Rect +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Response +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import java.io.ByteArrayOutputStream + +// https://greasyfork.org/en/scripts/467901-k-manga-ripper +class ImageInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val fragment = request.url.fragment + if (fragment.isNullOrEmpty() || !fragment.startsWith("scramble_seed=")) { + return chain.proceed(request) + } + + val seed = fragment.substringAfter("scramble_seed=").toLong() + val response = chain.proceed(request) + val descrambledBody = descrambleImage(response.body, seed) + + return response.newBuilder().body(descrambledBody).build() + } + + private class Coord(val x: Int, val y: Int) + private class CoordPair(val source: Coord, val dest: Coord) + + private fun xorshift32(seed: UInt): UInt { + var n = seed + n = n xor (n shl 13) + n = n xor (n shr 17) + n = n xor (n shl 5) + return n + } + + private fun getUnscrambledCoords(seed: Long): List { + var seed32 = seed.toUInt() + val pairs = mutableListOf>() + + for (i in 0 until 16) { + seed32 = xorshift32(seed32) + pairs.add(seed32 to i) + } + + pairs.sortBy { it.first } + val sortedVal = pairs.map { it.second } + + return sortedVal.mapIndexed { i, e -> + CoordPair( + source = Coord(x = e % 4, y = e / 4), + dest = Coord(x = i % 4, y = i / 4), + ) + } + } + + private fun descrambleImage(responseBody: ResponseBody, seed: Long): ResponseBody { + val unscrambledCoords = getUnscrambledCoords(seed) + val originalBitmap = BitmapFactory.decodeStream(responseBody.byteStream()) + ?: throw Exception("Failed to decode image stream") + + val originalWidth = originalBitmap.width + val originalHeight = originalBitmap.height + + val descrambledBitmap = Bitmap.createBitmap(originalWidth, originalHeight, originalBitmap.config) + val canvas = Canvas(descrambledBitmap) + + val getTileDimension = { size: Int -> (size / 8 * 8) / 4 } + val tileWidth = getTileDimension(originalWidth) + val tileHeight = getTileDimension(originalHeight) + + unscrambledCoords.forEach { coord -> + val sx = coord.source.x * tileWidth + val sy = coord.source.y * tileHeight + val dx = coord.dest.x * tileWidth + val dy = coord.dest.y * tileHeight + + val srcRect = Rect(sx, sy, sx + tileWidth, sy + tileHeight) + val destRect = Rect(dx, dy, dx + tileWidth, dy + tileHeight) + + canvas.drawBitmap(originalBitmap, srcRect, destRect, null) + } + originalBitmap.recycle() + + val outputStream = ByteArrayOutputStream() + descrambledBitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream) + descrambledBitmap.recycle() + + return outputStream.toByteArray().toResponseBody("image/jpeg".toMediaType()) + } +} diff --git a/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/KManga.kt b/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/KManga.kt new file mode 100644 index 000000000..5446fb077 --- /dev/null +++ b/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/KManga.kt @@ -0,0 +1,394 @@ +package eu.kanade.tachiyomi.extension.en.kmanga + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservable +import eu.kanade.tachiyomi.network.interceptor.rateLimitHost +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.util.asJsoup +import keiyoushi.utils.firstInstance +import keiyoushi.utils.parseAs +import keiyoushi.utils.tryParse +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.FormBody +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okio.ByteString.Companion.encodeUtf8 +import rx.Observable +import java.net.URLDecoder +import java.text.SimpleDateFormat +import java.util.Locale + +class KManga : HttpSource() { + + override val name = "K Manga" + override val baseUrl = "https://kmanga.kodansha.com" + override val lang = "en" + override val supportsLatest = false + + private val apiUrl = "https://api.kmanga.kodansha.com" + private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) + private val pageLimit = 25 + private val searchLimit = 25 + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", "$baseUrl/") + .add("X-Requested-With", "XMLHttpRequest") + .add("x-kmanga-platform", "3") + + override val client: OkHttpClient = network.cloudflareClient.newBuilder() + .addInterceptor(ImageInterceptor()) + .rateLimitHost(apiUrl.toHttpUrl(), 1) + .build() + + // Popular + override fun popularMangaRequest(page: Int): Request { + val offset = (page - 1) * pageLimit + val url = apiUrl.toHttpUrl().newBuilder() + .addPathSegments("ranking/all") + .addQueryParameter("ranking_id", "12") + .addQueryParameter("offset", offset.toString()) + .addQueryParameter("limit", (pageLimit + 1).toString()) + .build() + + return hashedGet(url) + } + + override fun popularMangaParse(response: Response): MangasPage { + val rankingResult = response.parseAs() + val titleIds = rankingResult.rankingTitleList.map { it.id.toString() } + + if (titleIds.isEmpty()) { + return MangasPage(emptyList(), false) + } + + val hasNextPage = titleIds.size > pageLimit + val mangaIdsToFetch = if (hasNextPage) titleIds.dropLast(1) else titleIds + + val detailsUrl = apiUrl.toHttpUrl().newBuilder() + .addPathSegments("title/list") + .addQueryParameter("title_id_list", mangaIdsToFetch.joinToString(",")) + .build() + + val detailsRequest = hashedGet(detailsUrl) + val detailsResponse = client.newCall(detailsRequest).execute() + + if (!detailsResponse.isSuccessful) { + throw Exception("Failed to fetch title details: ${detailsResponse.code} - ${detailsResponse.body.string()}") + } + + val detailsResult = detailsResponse.parseAs() + val mangas = detailsResult.titleList.map { manga -> + SManga.create().apply { + url = "/title/${manga.titleId}" + title = manga.titleName + thumbnail_url = manga.thumbnailImageUrl ?: manga.bannerImageUrl + } + } + return MangasPage(mangas, hasNextPage) + } + + // Search + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + return if (query.startsWith(PREFIX_SEARCH)) { + val titleId = query.removePrefix(PREFIX_SEARCH) + fetchMangaDetails( + SManga.create().apply { url = "/title/$titleId" }, + ).map { manga -> + MangasPage(listOf(manga.apply { url = "/title/$titleId" }), false) + } + } else { + super.fetchSearchManga(page, query, filters) + } + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val genreFilter = filters.firstInstance() + + if (query.isNotBlank()) { + val offset = (page - 1) * searchLimit + val url = apiUrl.toHttpUrl().newBuilder() + .addPathSegments("search/title") + .addQueryParameter("keyword", query) + .addQueryParameter("offset", offset.toString()) + .addQueryParameter("limit", searchLimit.toString()) + .build() + return hashedGet(url) + } + + if (genreFilter.state != 0) { + val url = baseUrl.toHttpUrl().newBuilder() + .addPathSegments(genreFilter.toUriPart().removePrefix("/")) + .build() + return GET(url, headers) + } + return popularMangaRequest(page) + } + + override fun searchMangaParse(response: Response): MangasPage { + if (response.request.url.host.contains("api.")) { + val result = response.parseAs() + val mangas = result.titleList.map { manga -> + SManga.create().apply { + url = "/title/${manga.titleId}" + title = manga.titleName + thumbnail_url = manga.thumbnailImageUrl + } + } + return MangasPage(mangas, mangas.size >= searchLimit) + } + + if (response.request.url.toString().contains("/search/genre/")) { + val document = response.asJsoup() + val nuxtData = document.selectFirst("script#__NUXT_DATA__")?.data() + ?: return MangasPage(emptyList(), false) + + val rootArray = nuxtData.parseAs() + fun resolve(ref: JsonElement): JsonElement = rootArray[ref.jsonPrimitive.content.toInt()] + + val genreResultObject = rootArray.firstOrNull { it is JsonObject && "title_list" in it.jsonObject } + ?: return MangasPage(emptyList(), false) + + val mangaRefs = resolve(genreResultObject.jsonObject["title_list"]!!).jsonArray + + val mangas = mangaRefs.map { ref -> + val mangaObject = resolve(ref).jsonObject + SManga.create().apply { + url = "/title/${resolve(mangaObject["title_id"]!!).jsonPrimitive.content}" + title = resolve(mangaObject["title_name"]!!).jsonPrimitive.content + thumbnail_url = mangaObject["thumbnail_image_url"]?.let { resolve(it).jsonPrimitive.content } + } + } + return MangasPage(mangas, false) + } + return popularMangaParse(response) + } + + // Details + override fun mangaDetailsParse(response: Response): SManga { + val document = response.asJsoup() + val nuxtData = document.selectFirst("script#__NUXT_DATA__")?.data() + ?: throw Exception("Could not find Nuxt data") + + val rootArray = nuxtData.parseAs() + fun resolve(ref: JsonElement): JsonElement = rootArray[ref.jsonPrimitive.content.toInt()] + + val titleDetailsObject = rootArray.first { it is JsonObject && it.jsonObject.containsKey("title_in_japanese") }.jsonObject + + val genreMap = buildMap { + rootArray.forEach { element -> + if (element is JsonObject && element.jsonObject.containsKey("genre_id")) { + val genreObject = element.jsonObject + val id = resolve(genreObject["genre_id"]!!).jsonPrimitive.content.toInt() + val name = resolve(genreObject["genre_name"]!!).jsonPrimitive.content + put(id, name) + } + } + } + + return SManga.create().apply { + title = resolve(titleDetailsObject["title_name"]!!).jsonPrimitive.content + author = resolve(titleDetailsObject["author_text"]!!).jsonPrimitive.content + description = resolve(titleDetailsObject["introduction_text"]!!).jsonPrimitive.content + thumbnail_url = titleDetailsObject["thumbnail_image_url"]?.let { resolve(it).jsonPrimitive.content } + val genreIdRefs = resolve(titleDetailsObject["genre_id_list"]!!).jsonArray + val genreIds = genreIdRefs.map { resolve(it).jsonPrimitive.content.toInt() } + genre = genreIds.mapNotNull { genreMap[it] }.joinToString() + } + } + + // Chapters + override fun chapterListRequest(manga: SManga): Request { + return GET(baseUrl + manga.url, headers) + } + + override fun chapterListParse(response: Response): List { + val document = response.asJsoup() + val nuxtData = document.selectFirst("script#__NUXT_DATA__")?.data() + ?: throw Exception("Could not find Nuxt data") + + val rootArray = nuxtData.parseAs() + fun resolve(ref: JsonElement): JsonElement = rootArray[ref.jsonPrimitive.content.toInt()] + + val titleDetailsObject = rootArray.first { it is JsonObject && it.jsonObject.containsKey("episode_id_list") }.jsonObject + val episodeIdRefs = resolve(titleDetailsObject["episode_id_list"]!!).jsonArray + val episodeIds = episodeIdRefs.map { resolve(it).jsonPrimitive.content } + + val (birthday, expires) = getBirthdayCookie(response.request.url) + + val formBody = FormBody.Builder() + .add("episode_id_list", episodeIds.joinToString(",")) + .build() + + val params = (0 until formBody.size).associate { formBody.name(it) to formBody.value(it) } + val hash = generateHash(params, birthday, expires) + + val postHeaders = headersBuilder() + .add("Accept", "application/json") + .add("Content-Type", "application/x-www-form-urlencoded") + .add("Origin", baseUrl) + .add("x-kmanga-is-crawler", "false") + .add("x-kmanga-hash", hash) + .build() + + val apiRequest = POST("$apiUrl/episode/list", postHeaders, formBody) + val apiResponse = client.newCall(apiRequest).execute() + + if (!apiResponse.isSuccessful) { + throw Exception("API request failed with code ${apiResponse.code}: ${apiResponse.body.string()}") + } + + val result = apiResponse.parseAs() + + return result.episodeList.map { chapter -> + SChapter.create().apply { + url = "/title/${chapter.titleId}/episode/${chapter.episodeId}" + name = if (chapter.point > 0 && chapter.badge != 3 && chapter.rentalFinishTime == null) { + "🔒 ${chapter.episodeName}" + } else { + chapter.episodeName + } + date_upload = dateFormat.tryParse(chapter.startTime) + } + }.reversed() + } + + private fun getBirthdayCookie(url: HttpUrl): Pair { + val cookies = client.cookieJar.loadForRequest(url) + val birthdayCookie = cookies.firstOrNull { it.name == "birthday" }?.value + + return if (birthdayCookie != null) { + try { + val decoded = URLDecoder.decode(birthdayCookie, "UTF-8") + val cookieData = decoded.parseAs() + cookieData.value to cookieData.expires.toString() + } catch (e: Exception) { + // Fallback to default if cookie is malformed + "2000-01" to (System.currentTimeMillis() / 1000 + 315360000).toString() + } + } else { + // Default for logged out users or users without the cookie to bypass age restrictions + "2000-01" to (System.currentTimeMillis() / 1000 + 315360000).toString() + } + } + + // https://kmanga.kodansha.com/_nuxt/vl9so/entry-CSwIbMdW.js + private fun generateHash(params: Map, birthday: String, expires: String): String { + val paramStrings = params.toSortedMap().map { (key, value) -> + getHashedParam(key, value) + } + + val joinedParams = paramStrings.joinToString(",") + val hash1 = joinedParams.encodeUtf8().sha256().hex() + + val cookieHash = getHashedParam(birthday, expires) + + val finalString = "$hash1$cookieHash" + return finalString.encodeUtf8().sha512().hex() + } + + private fun getHashedParam(key: String, value: String): String { + val keyHash = key.encodeUtf8().sha256().hex() + val valueHash = value.encodeUtf8().sha512().hex() + return "${keyHash}_$valueHash" + } + + // Pages + override fun fetchPageList(chapter: SChapter): Observable> { + return client.newCall(pageListRequest(chapter)) + .asObservable() + .map { response -> + if (!response.isSuccessful) { + if (response.code == 400) { + throw Exception("This chapter is locked. Log in via WebView and rent or purchase the chapter to read.") + } + throw Exception("HTTP error ${response.code}") + } + pageListParse(response) + } + } + + override fun pageListParse(response: Response): List { + val apiResponse = response.parseAs() + val seed = apiResponse.scrambleSeed + return apiResponse.pageList.mapIndexed { index, imageUrl -> + Page(index = index, imageUrl = "$imageUrl#scramble_seed=$seed") + } + } + + override fun pageListRequest(chapter: SChapter): Request { + val episodeId = chapter.url.substringAfter("episode/") + val url = "$apiUrl/web/episode/viewer".toHttpUrl().newBuilder() + .addQueryParameter("episode_id", episodeId) + .build() + + return hashedGet(url) + } + + private fun hashedGet(url: HttpUrl): Request { + val (birthday, expires) = getBirthdayCookie(url) + val queryParams = url.queryParameterNames.associateWith { url.queryParameter(it)!! } + val hash = generateHash(queryParams, birthday, expires) + val newHeaders = headersBuilder() + .add("x-kmanga-hash", hash) + .build() + return GET(url, newHeaders) + } + + // Filters + override fun getFilterList() = FilterList( + Filter.Header("NOTE: Search query will ignore genre filter"), + GenreFilter(), + ) + + private class GenreFilter : UriPartFilter( + "Genre", + arrayOf( + Pair("All", "/ranking"), + Pair("Romance・Romcom", "/search/genre/1"), + Pair("Horror・Mystery・Suspense", "/search/genre/2"), + Pair("Gag・Comedy・Slice-of-Life", "/search/genre/3"), + Pair("SF・Fantasy", "/search/genre/4"), + Pair("Sports", "/search/genre/5"), + Pair("Drama", "/search/genre/6"), + Pair("Outlaws・Underworld・Punks", "/search/genre/7"), + Pair("Action・Battle", "/search/genre/8"), + Pair("Isekai・Super Powers", "/search/genre/9"), + Pair("One-off Books", "/search/genre/10"), + Pair("Shojo/josei", "/search/genre/11"), + Pair("Yaoi/BL", "/search/genre/12"), + Pair("LGBTQ", "/search/genre/13"), + Pair("Yuri/GL", "/search/genre/14"), + Pair("Anime", "/search/genre/15"), + Pair("Award Winner", "/search/genre/16"), + ), + ) + + private open class UriPartFilter(displayName: String, val vals: Array>) : + Filter.Select(displayName, vals.map { it.first }.toTypedArray()) { + fun toUriPart() = vals[state].second + } + + companion object { + const val PREFIX_SEARCH = "id:" + } + + // Unsupported + override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException() + override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException() + override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException() +} diff --git a/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/KMangaUrlActivity.kt b/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/KMangaUrlActivity.kt new file mode 100644 index 000000000..755021a6a --- /dev/null +++ b/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/KMangaUrlActivity.kt @@ -0,0 +1,34 @@ +package eu.kanade.tachiyomi.extension.en.kmanga + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import android.util.Log +import kotlin.system.exitProcess + +class KMangaUrlActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val pathSegments = intent?.data?.pathSegments + if (pathSegments != null && pathSegments.size > 1) { + val titleId = pathSegments[1] + val mainIntent = Intent().apply { + action = "eu.kanade.tachiyomi.SEARCH" + putExtra("query", "${KManga.PREFIX_SEARCH}$titleId") + putExtra("filter", packageName) + } + + try { + startActivity(mainIntent) + } catch (e: ActivityNotFoundException) { + Log.e("KMangaUrlActivity", e.toString()) + } + } else { + Log.e("KMangaUrlActivity", "Could not parse URI from intent $intent") + } + + finish() + exitProcess(0) + } +}