diff --git a/build.gradle.kts b/build.gradle.kts index b7e50e5..b549520 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } group = "com.codingtestkit" -version = "1.6.0" +version = "1.6.1" repositories { mavenCentral() @@ -33,7 +33,7 @@ intellijPlatform { pluginConfiguration { id = "com.codingtestkit" name = "CodingTestKit" - version = "1.6.0" + version = "1.6.1" ideaVersion { sinceBuild = "243" untilBuild = provider { null } diff --git a/docs/dev/linux-jcef-test.md b/docs/dev/linux-jcef-test.md new file mode 100644 index 0000000..58bc3d5 --- /dev/null +++ b/docs/dev/linux-jcef-test.md @@ -0,0 +1,72 @@ +# Linux JCEF 폴백 테스트 가이드 (이슈 #9 / #30) + +## 배경 — 무엇을 확인하려는가 + +제보자 환경: **Linux x86_64 + RustRover** (JetBrains). 제보 GIF 분석 결과: + +- 코드포스 문제를 가져오면 브라우저가 페이지를 **완벽히 로드**함 (Cloudflare 챌린지도 없음, status 200) +- 그런데 플러그인이 그 HTML을 추출하지 못하고 **"Fetching..."에서 멈춤** +- 즉 병목은 "Cloudflare 통과"가 아니라 "JBCefJSQuery 추출 콜백이 안 옴" +- **맥/윈도우에서는 정상** — Linux 특유의 JCEF 동작 차이로 추정 + +수정(#30): 브라우저가 페이지를 로드하면 cf_clearance 쿠키가 생기므로, +JS 추출 대신 **그 쿠키로 Jsoup 재시도**하는 우회 경로를 병행. 추출 콜백이 +죽는 환경에서도 문제를 가져올 수 있어야 함. 이 가이드는 그게 실제로 +동작하는지 Linux에서 확인하는 절차. + +## 1. 환경 준비 — 반드시 x86_64 + +제보자가 x86_64이므로 아키텍처를 맞춘다: +- **권장**: x86_64 클라우드 VM (AWS/GCP 무료 티어 Ubuntu 등) — Apple Silicon Mac의 + UTM x86 에뮬레이션은 매우 느리고 JCEF가 불안정할 수 있음 +- 데스크톱 환경 필요 (JCEF는 GUI). 헤드리스면 xvfb 필요 +- 가능하면 **RustRover**로도 재현 (IDEA와 JCEF 빌드 차이 가능성) + +```bash +sudo apt update && sudo apt install -y openjdk-21-jdk git +git clone https://github.com/dj258255/codingtestkit.git +cd codingtestkit +git checkout fix/codeforces-visible-fallback # 수정 브랜치 +``` + +## 2. 그냥 실행해서 재현 시도 (강제 패치 없이) + +먼저 제보자와 동일 조건 그대로: + +```bash +./gradlew runIde +``` + +문제 탭 → Codeforces → 아직 안 받아본 번호(예: 1A) → 가져오기. + +- **문제가 표시되면** → 수정 성공 (쿠키 재시도가 추출 실패를 살림) +- **여전히 "Fetching..."에서 멈추면** → 로그 확인 (아래 4번) + +## 3. 강제 폴백으로 우회 경로만 집중 테스트 + +Jsoup이 바로 성공해버리면 JCEF 경로를 안 타므로, 강제로 폴백을 태운다: + +```bash +./scripts/force-cf-fallback.sh on +./gradlew runIde +# 테스트 후 +./scripts/force-cf-fallback.sh off +``` + +## 4. 로그 진단 + +```bash +tail -f build/idea-sandbox/*/log/idea.log | grep --line-buffered -iE "Codeforces JCEF|cookie-retry" +``` + +핵심 라인: +- `JCEF loaded ... (status=200)` → 브라우저는 페이지 로드 성공 +- `cookie-retry succeeded` → **우회 경로가 문제를 가져옴 (수정 동작 확인)** +- `fetch OK` → 기존 JS 추출 경로가 성공 (맥에서 보이는 것) +- 둘 다 없이 타임아웃 → 우회 경로도 실패 → 로그 전체를 이슈에 첨부해 추가 분석 + +## 5. 보이는 다이얼로그 폴백 확인 + +OSR·쿠키재시도 둘 다 실패하는 최악의 경우, 제목줄·닫기 버튼이 있는 +"Codeforces 문제 가져오기" 다이얼로그가 떠야 한다. 거기서 페이지가 보이고 +(필요하면 직접 Cloudflare 통과), 통과 후 자동으로 문제가 추출·표시되면 OK. diff --git a/docs/screenshots/boj-login.png b/docs/screenshots/boj-login.png new file mode 100644 index 0000000..5bf0add Binary files /dev/null and b/docs/screenshots/boj-login.png differ diff --git a/docs/screenshots/boj-submit-code.png b/docs/screenshots/boj-submit-code.png new file mode 100644 index 0000000..f405172 Binary files /dev/null and b/docs/screenshots/boj-submit-code.png differ diff --git a/docs/screenshots/boj-submit-confirm.png b/docs/screenshots/boj-submit-confirm.png new file mode 100644 index 0000000..c776a32 Binary files /dev/null and b/docs/screenshots/boj-submit-confirm.png differ diff --git a/docs/screenshots/boj-submit-success.png b/docs/screenshots/boj-submit-success.png new file mode 100644 index 0000000..a2bfa93 Binary files /dev/null and b/docs/screenshots/boj-submit-success.png differ diff --git a/scripts/force-cf-fallback.sh b/scripts/force-cf-fallback.sh new file mode 100755 index 0000000..25ed9af --- /dev/null +++ b/scripts/force-cf-fallback.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# 코드포스 Jsoup(HTTP) 경로를 일부러 실패시켜 JCEF 폴백을 강제로 태우는 개발용 토글. +# 이슈 #9/#30 Linux 테스트 전용. 커밋하지 말 것. +# ./scripts/force-cf-fallback.sh on # 패치 적용 +# ./scripts/force-cf-fallback.sh off # 되돌림 +set -euo pipefail +FILE="src/main/kotlin/com/codingtestkit/service/CodeforcesCrawler.kt" +MARK="// FORCE_CF_FALLBACK_TEST" +ANCHOR=' val (contestId, letter) = parseProblemId(problemId)' + +cd "$(dirname "$0")/.." + +case "${1:-}" in + on) + if grep -q "$MARK" "$FILE"; then echo "이미 적용됨"; exit 0; fi + # fetchProblem 본문 첫 줄 앞에 강제 throw 삽입 + perl -0pi -e "s|\Q$ANCHOR\E| throw RuntimeException(\"$MARK: forced 403\")\n$ANCHOR|" "$FILE" + echo "패치 적용됨 — Jsoup 경로가 항상 실패해 JCEF 폴백을 탑니다." + ;; + off) + perl -0pi -e "s| throw RuntimeException\(\"\Q$MARK\E: forced 403\"\)\n||" "$FILE" + echo "패치 되돌림." + git diff --stat "$FILE" + ;; + *) + echo "사용법: $0 {on|off}"; exit 1;; +esac diff --git a/src/main/kotlin/com/codingtestkit/service/CodeforcesJcefFetcher.kt b/src/main/kotlin/com/codingtestkit/service/CodeforcesJcefFetcher.kt index f4829c9..52718f5 100644 --- a/src/main/kotlin/com/codingtestkit/service/CodeforcesJcefFetcher.kt +++ b/src/main/kotlin/com/codingtestkit/service/CodeforcesJcefFetcher.kt @@ -125,49 +125,69 @@ object CodeforcesJcefFetcher { private var browser: JBCefBrowser? = null private var timeoutTimer: Timer? = null + private var isOsr = false + /** - * 사용자에게 보이지 않는 스크래핑용 브라우저 생성. + * 사용자에게 보이지 않는 스크래핑용 브라우저를 생성만(네이티브 초기화 X) 한다. * * 1순위는 오프스크린 렌더링(OSR): OS 창 자체가 없어 어떤 플랫폼에서도 * 노출될 수 없다. 화면 밖 좌표(-3000,-3000)로 숨기는 기존 방식은 * Linux(Wayland 등)에서 WM이 창 위치 지정을 무시해 제목줄 없는 * 플로팅 창으로 그대로 노출됐다 (이슈 #9). + * + * 중요: 여기서는 브라우저를 만들되 **네이티브 생성(createImmediately)은 하지 않는다.** + * JBCefJSQuery·로드 핸들러를 먼저 등록한 뒤 start()에서 생성해야 메시지 라우터가 + * 제때 붙는다. setCreateImmediately(true)로 먼저 생성하면 일부 환경(Linux)에서 + * JSQuery 콜백이 아예 안 붙어 추출이 멈춘다 (이슈 #30). */ - private fun createHiddenBrowser(url: String): JBCefBrowser { + private fun buildHiddenBrowser(url: String): JBCefBrowser { try { val osr = JBCefBrowser.createBuilder() .setUrl(url) .setOffScreenRendering(true) - .setCreateImmediately(true) + .setCreateImmediately(false) .build() - // OSR는 컴포넌트 크기를 뷰포트로 사용하므로 명시적으로 지정 osr.component.setBounds(0, 0, 1024, 768) + osr.component.preferredSize = Dimension(1024, 768) + isOsr = true return osr } catch (e: Exception) { LOG.info("[CodingTestKit] JCEF OSR unavailable, falling back to hidden frame: ${e.message}") } // 폴백: 화면 밖 숨김 프레임 (일부 Linux WM에서는 노출될 수 있음) - val windowed = JBCefBrowser(url) - frame = JFrame().apply { - type = Window.Type.UTILITY - isUndecorated = true - size = Dimension(1024, 768) - setLocation(-3000, -3000) - contentPane.add(windowed.component) - isVisible = true + isOsr = false + return JBCefBrowser(url) + } + + /** 핸들러 등록이 끝난 뒤 브라우저 네이티브를 생성하고 로딩을 시작한다. */ + private fun realizeBrowser(br: JBCefBrowser) { + if (isOsr) { + br.createImmediately() + // OSR 뷰포트가 0×0으로 잡혀 렌더링·JS 실행이 안 되는 환경 대응 (이슈 #30) + try { br.cefBrowser.wasResized(1024, 768) } catch (_: Throwable) {} + } else { + frame = JFrame().apply { + type = Window.Type.UTILITY + isUndecorated = true + size = Dimension(1024, 768) + setLocation(-3000, -3000) + contentPane.add(br.component) // 표시가 네이티브 생성을 트리거 + isVisible = true + } } - return windowed } fun start() { val (contestId, letter) = CodeforcesCrawler.parseProblemId(problemId) val url = "https://codeforces.com/problemset/problem/$contestId/$letter?locale=en" - browser = createHiddenBrowser(url) + val br = buildHiddenBrowser(url) + browser = br - LOG.info("[CodingTestKit] Codeforces JCEF fetch started: $problemId") + LOG.info("[CodingTestKit] Codeforces JCEF fetch started: $problemId (osr=$isOsr)") - val jsQuery = JBCefJSQuery.create(browser as JBCefBrowserBase) + // 1) JSQuery·핸들러를 먼저 등록 (메시지 라우터가 네이티브 생성 전에 붙도록) + val jsQuery = JBCefJSQuery.create(br as JBCefBrowserBase) jsQuery.addHandler { html -> handleHtml(html) JBCefJSQuery.Response("") @@ -177,16 +197,54 @@ object CodeforcesJcefFetcher { complete(null, "JCEF internal timeout") }.apply { isRepeats = false; start() } - browser!!.jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() { + br.jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() { override fun onLoadEnd(cefBrowser: CefBrowser?, cefFrame: CefFrame?, httpStatusCode: Int) { if (cefFrame?.isMain != true || done.get()) return - LOG.info("[CodingTestKit] Codeforces JCEF loaded: ${cefBrowser?.url} (status=$httpStatusCode)") + val url = cefBrowser?.url ?: "" + LOG.info("[CodingTestKit] Codeforces JCEF loaded: $url (status=$httpStatusCode)") // Cloudflare 챌린지 페이지일 수 있으므로 .problem-statement가 // 나타날 때까지 폴링. 챌린지 통과 후 리다이렉트되면 onLoadEnd가 // 다시 불리고 새 문서에 폴러가 재주입된다. startPolling(cefBrowser, jsQuery) + // 문제 페이지가 로드되면 cf_clearance 쿠키가 생기므로, JS 추출(위 폴러)이 + // 일부 환경(Linux/RustRover)에서 콜백이 안 오는 경우를 대비해 쿠키로 + // Jsoup을 재시도하는 경로를 병행한다. 먼저 성공하는 쪽이 이긴다 (이슈 #30). + if (httpStatusCode == 200 && url.contains("/problem")) { + tryCookieRetry() + } + } + }, br.cefBrowser) + + // 2) 모든 핸들러 등록이 끝난 뒤에야 네이티브 브라우저를 생성·로딩 + realizeBrowser(br) + } + + private val cookieRetryStarted = AtomicBoolean(false) + + /** + * 브라우저가 페이지를 로드해 cf_clearance가 설정된 뒤, JS 추출 대신 + * 그 쿠키로 Jsoup을 재시도한다. JBCefJSQuery 콜백이 동작하지 않는 + * 환경에서도 문제를 가져올 수 있는 우회 경로 (이슈 #30). + */ + private fun tryCookieRetry() { + if (!cookieRetryStarted.compareAndSet(false, true)) return + Thread { + try { + // Cloudflare 핸드셰이크가 쿠키를 심을 시간을 준다 + Thread.sleep(1500) + if (done.get()) return@Thread + // CodeforcesCrawler.fetchProblem이 내부적으로 전역 쿠키(cf_clearance)를 + // 읽어 병합하므로(#21), 별도 인자 없이 호출해도 방금 설정된 쿠키를 쓴다. + val problem = CodeforcesCrawler.fetchProblem(problemId) + if (!done.get() && (problem.title.isNotBlank() || problem.description.length > 50)) { + LOG.info("[CodingTestKit] Codeforces JCEF cookie-retry succeeded") + complete(problem) + } + } catch (e: Exception) { + // Jsoup 재시도 실패 — JS 폴러가 아직 성공할 수 있으므로 무시 + LOG.info("[CodingTestKit] Codeforces cookie-retry failed: ${e.message}") } - }, browser!!.cefBrowser) + }.apply { isDaemon = true; start() } } private fun startPolling(cefBrowser: CefBrowser?, jsQuery: JBCefJSQuery) { diff --git a/src/main/kotlin/com/codingtestkit/service/I18n.kt b/src/main/kotlin/com/codingtestkit/service/I18n.kt index acf1a5d..5a5507c 100644 --- a/src/main/kotlin/com/codingtestkit/service/I18n.kt +++ b/src/main/kotlin/com/codingtestkit/service/I18n.kt @@ -13,15 +13,23 @@ object I18n { var currentLang: Lang = Lang.KO private set + /** 언어 변경 리스너. UI가 즉시 다시 그려지도록 (도구 창 콘텐츠 재생성 등). */ + private val changeListeners = java.util.concurrent.CopyOnWriteArrayList<() -> Unit>() + + fun addChangeListener(listener: () -> Unit) { changeListeners.add(listener) } + fun removeChangeListener(listener: () -> Unit) { changeListeners.remove(listener) } + init { currentLang = loadSavedLanguage() } fun setLanguage(lang: Lang) { + if (lang == currentLang) return currentLang = lang if (ApplicationManager.getApplication() != null) { PropertiesComponent.getInstance().setValue("codingtestkit.language", lang.name) } + changeListeners.forEach { runCatching { it() } } } /** 현재 언어에 따라 한국어/영어 문자열 반환 */ diff --git a/src/main/kotlin/com/codingtestkit/ui/CodeforcesFetchDialog.kt b/src/main/kotlin/com/codingtestkit/ui/CodeforcesFetchDialog.kt new file mode 100644 index 0000000..d30c320 --- /dev/null +++ b/src/main/kotlin/com/codingtestkit/ui/CodeforcesFetchDialog.kt @@ -0,0 +1,179 @@ +package com.codingtestkit.ui + +import com.codingtestkit.model.Problem +import com.codingtestkit.service.CodeforcesCrawler +import com.codingtestkit.service.I18n +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.jcef.JBCefApp +import com.intellij.ui.jcef.JBCefBrowser +import com.intellij.ui.jcef.JBCefBrowserBase +import com.intellij.ui.jcef.JBCefJSQuery +import org.cef.browser.CefBrowser +import org.cef.browser.CefFrame +import org.cef.handler.CefLoadHandlerAdapter +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Dimension +import javax.swing.* + +/** + * Codeforces 문제 가져오기 다이얼로그 (보이는 브라우저 폴백, 이슈 #30). + * + * 오프스크린(OSR) 폴백이 실패하는 환경 — 예: Linux/Wayland에서 OSR 렌더링이 + * 안 되거나, Cloudflare가 클릭이 필요한 인터랙티브 챌린지를 내는 경우 — 을 위한 + * 최종 폴백. 제목줄·닫기 버튼이 있는 정식 다이얼로그에 실브라우저를 띄워 + * 사용자가 직접 챌린지를 통과하게 하고, .problem-statement가 나타나면 자동으로 + * HTML을 추출해 CodeforcesCrawler 파서에 넘기고 닫는다. (SweaFetchDialog와 동일 패턴) + */ +class CodeforcesFetchDialog( + project: Project, + private val problemId: String +) : DialogWrapper(project) { + + private var problem: Problem? = null + private var extracted = false + + private val statusLabel = JLabel(I18n.t( + "페이지를 여는 중입니다. Cloudflare 확인 화면이 뜨면 완료해 주세요.", + "Opening the page. If a Cloudflare check appears, please complete it." + )).apply { foreground = Color.GRAY } + + init { + title = I18n.t("Codeforces 문제 가져오기", "Fetch Codeforces Problem") + setSize(900, 700) + init() + } + + override fun createActions(): Array { + myCancelAction.putValue(Action.NAME, I18n.t("취소", "Cancel")) + return arrayOf(cancelAction) + } + + override fun createCenterPanel(): JComponent { + if (!JBCefApp.isSupported()) { + return JLabel("${I18n.t( + "JCEF를 사용할 수 없어 Codeforces 문제를 가져올 수 없습니다.", + "Cannot fetch Codeforces problems: JCEF is not available." + )}") + } + return createBrowserPanel() + } + + private fun createBrowserPanel(): JComponent { + val (contestId, letter) = CodeforcesCrawler.parseProblemId(problemId) + val url = "https://codeforces.com/problemset/problem/$contestId/$letter?locale=en" + + val panel = JPanel(BorderLayout()) + panel.preferredSize = Dimension(880, 660) + + val header = JPanel(BorderLayout(8, 8)).apply { + border = BorderFactory.createEmptyBorder(10, 12, 8, 12) + } + header.add(JLabel("${I18n.t("Codeforces #$problemId", "Codeforces #$problemId")}"), BorderLayout.NORTH) + header.add(statusLabel, BorderLayout.CENTER) + panel.add(header, BorderLayout.NORTH) + + // 사용자에게 보이는 브라우저 (챌린지 직접 통과용) + val browser = JBCefBrowser(url) + val bWrapper = browser.component + bWrapper.preferredSize = Dimension(860, 580) + panel.add(bWrapper, BorderLayout.CENTER) + + val jsQuery = JBCefJSQuery.create(browser as JBCefBrowserBase) + jsQuery.addHandler { html -> + handleHtml(html) + JBCefJSQuery.Response("") + } + + browser.jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() { + override fun onLoadEnd(cefBrowser: CefBrowser?, frame: CefFrame?, httpStatusCode: Int) { + if (frame?.isMain != true || extracted) return + val url = cefBrowser?.url ?: "" + // 챌린지 통과 후 리다이렉트되면 onLoadEnd가 다시 불리고 폴러가 재주입됨 + startPolling(cefBrowser, jsQuery) + // 사용자가 챌린지를 통과해 문제 페이지가 로드되면 cf_clearance가 생기므로, + // JS 추출이 안 되는 환경을 대비해 쿠키로 Jsoup 재시도를 병행 (이슈 #30) + if (httpStatusCode == 200 && url.contains("/problem")) { + tryCookieRetry() + } + } + }, browser.cefBrowser) + + return panel + } + + @Volatile private var cookieRetryStarted = false + + /** 페이지 로드 후 cf_clearance 쿠키로 Jsoup 재시도 (JS 추출 우회, 이슈 #30) */ + private fun tryCookieRetry() { + if (cookieRetryStarted) return + cookieRetryStarted = true + Thread { + try { + Thread.sleep(1500) + if (extracted) return@Thread + val parsed = CodeforcesCrawler.fetchProblem(problemId) + if (parsed.title.isNotBlank() || parsed.description.length > 50) { + SwingUtilities.invokeLater { + if (extracted) return@invokeLater + extracted = true + problem = parsed + statusLabel.text = I18n.t("문제를 가져왔습니다.", "Problem fetched.") + statusLabel.foreground = Color(0, 120, 0) + doOKAction() + } + } + } catch (_: Exception) { /* 폴러가 아직 성공할 수 있으므로 무시 */ } + }.apply { isDaemon = true; start() } + } + + private fun startPolling(cefBrowser: CefBrowser?, jsQuery: JBCefJSQuery) { + if (cefBrowser == null || extracted) return + val queryJs = jsQuery.inject("html") + // .problem-statement가 나타날 때까지 폴링 (사용자가 챌린지를 통과하는 시간 포함, 최대 5분). + // 다이얼로그가 열려 있는 동안 계속 대기하므로 OSR 폴백보다 넉넉하게 잡는다. + val js = """ + (function() { + if (window.__ctkCfDlgPoller) return; + var attempts = 0; + window.__ctkCfDlgPoller = setInterval(function() { + attempts++; + if (document.querySelector('.problem-statement')) { + clearInterval(window.__ctkCfDlgPoller); + window.__ctkCfDlgPoller = null; + var html = document.documentElement.outerHTML; + $queryJs + } else if (attempts >= 600) { + clearInterval(window.__ctkCfDlgPoller); + window.__ctkCfDlgPoller = null; + } + }, 500); + })(); + """.trimIndent() + cefBrowser.executeJavaScript(js, cefBrowser.url, 0) + } + + private fun handleHtml(html: String) { + if (extracted) return + if (html.isBlank() || !html.contains("problem-statement")) return + extracted = true + SwingUtilities.invokeLater { + try { + val parsed = CodeforcesCrawler.parseProblemHtml(html, problemId) + if (parsed.title.isNotBlank() || parsed.description.length > 50) { + problem = parsed + statusLabel.text = I18n.t("문제를 가져왔습니다.", "Problem fetched.") + statusLabel.foreground = Color(0, 120, 0) + doOKAction() + } else { + extracted = false + } + } catch (_: Exception) { + extracted = false + } + } + } + + fun getProblem(): Problem? = problem +} diff --git a/src/main/kotlin/com/codingtestkit/ui/MainPanel.kt b/src/main/kotlin/com/codingtestkit/ui/MainPanel.kt index 185a13b..9ba7814 100644 --- a/src/main/kotlin/com/codingtestkit/ui/MainPanel.kt +++ b/src/main/kotlin/com/codingtestkit/ui/MainPanel.kt @@ -18,8 +18,14 @@ import com.intellij.util.ui.JBUI import java.awt.BorderLayout import java.awt.Dimension import javax.swing.JPanel +import javax.swing.SwingUtilities +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.ui.content.ContentFactory -class MainPanel(private val project: Project) : JPanel(BorderLayout()), Disposable { +class MainPanel( + private val project: Project, + private val initialTabIndex: Int = 0 +) : JPanel(BorderLayout()), Disposable { /** * 다른 툴윈도우(예: 프로젝트 뷰)와 같은 쪽에 세로 분할될 때, @@ -37,17 +43,26 @@ class MainPanel(private val project: Project) : JPanel(BorderLayout()), Disposab private val referencePanel = ReferencePanel() private val settingsPanel = SettingsPanel(project) private var lastLoadedFolder: String? = null + private val tabbedPane = JBTabbedPane() + + // UI 언어가 바뀌면 도구 창 콘텐츠를 통째로 다시 생성해 즉시 반영한다. + // 패널들이 생성 시점에 I18n.t()로 텍스트를 한 번만 읽으므로, 새로 만들어야 갱신됨. + private val languageListener: () -> Unit = { + SwingUtilities.invokeLater { rebuildToolWindow() } + } init { border = JBUI.Borders.empty() + I18n.addChangeListener(languageListener) - val tabbedPane = JBTabbedPane() tabbedPane.addTab(I18n.t("문제", "Problems"), AllIcons.Actions.Download, problemPanel) tabbedPane.addTab(I18n.t("테스트", "Tests"), AllIcons.Actions.Execute, testPanel) tabbedPane.addTab(I18n.t("템플릿", "Templates"), AllIcons.Actions.Copy, templatePanel) tabbedPane.addTab(I18n.t("타이머", "Timer"), AllIcons.Vcs.History, timerPanel) tabbedPane.addTab(I18n.t("레퍼런스", "Reference"), AllIcons.Actions.Preview, referencePanel) tabbedPane.addTab(I18n.t("설정", "Settings"), AllIcons.General.Settings, settingsPanel) + // 언어 변경으로 재생성될 때 이전에 보던 탭을 유지 (첫 탭으로 튕기지 않도록) + if (initialTabIndex in 0 until tabbedPane.tabCount) tabbedPane.selectedIndex = initialTabIndex add(tabbedPane, BorderLayout.CENTER) @@ -152,5 +167,21 @@ class MainPanel(private val project: Project) : JPanel(BorderLayout()), Disposab } } - override fun dispose() {} + /** + * 도구 창의 콘텐츠(이 MainPanel)를 새 MainPanel로 교체한다. + * 기존 콘텐츠를 dispose하면 이 인스턴스의 languageListener도 함께 해제된다. + */ + private fun rebuildToolWindow() { + val toolWindow = ToolWindowManager.getInstance(project).getToolWindow("CodingTestKit") ?: return + val contentManager = toolWindow.contentManager + val newPanel = MainPanel(project, tabbedPane.selectedIndex) + val newContent = ContentFactory.getInstance().createContent(newPanel, "", false) + newContent.setDisposer(newPanel) + contentManager.removeAllContents(true) // 기존 콘텐츠 dispose → 이 MainPanel.dispose() 호출 + contentManager.addContent(newContent) + } + + override fun dispose() { + I18n.removeChangeListener(languageListener) + } } diff --git a/src/main/kotlin/com/codingtestkit/ui/ProblemPanel.kt b/src/main/kotlin/com/codingtestkit/ui/ProblemPanel.kt index da6fc97..75bc518 100644 --- a/src/main/kotlin/com/codingtestkit/ui/ProblemPanel.kt +++ b/src/main/kotlin/com/codingtestkit/ui/ProblemPanel.kt @@ -134,6 +134,7 @@ class ProblemPanel(private val project: Project) : JPanel(BorderLayout()) { } private val useCef = try { JBCefApp.isSupported() } catch (_: Exception) { false } + private val isLinux = System.getProperty("os.name").lowercase().contains("linux") private var cefBrowser: JBCefBrowser? = null private var currentProblemHtml = "" @@ -809,11 +810,22 @@ class ProblemPanel(private val project: Project) : JPanel(BorderLayout()) { ProblemSource.CODEFORCES -> try { CodeforcesCrawler.fetchProblem(id, cookies) } catch (e: Exception) { - // Cloudflare가 Jsoup을 차단(403)하면 실브라우저(JCEF)로 폴백 - if (CodeforcesJcefFetcher.isAvailable()) { - CodeforcesJcefFetcher.fetchProblem(id) - ?: throw IllegalStateException("${e.message}\nJCEF fallback: ${CodeforcesJcefFetcher.lastError}") - } else throw e + // Cloudflare가 Jsoup을 차단(403)하면 실브라우저(JCEF) 폴백. + if (!CodeforcesJcefFetcher.isAvailable()) throw e + // Linux는 OSR(오프스크린)에서 CEF OnPaint/requestAnimationFrame이 돌지 않아 + // Cloudflare의 canvas 렌더 챌린지를 통과하지 못한다 — GitHub Actions(Xvfb) CI로 + // 재현·확인한 CEF 레벨 버그(chromiumembedded/cef#2800, #4166). 따라서 Linux는 + // OSR로 시간을 허비하지 않고 곧바로 보이는 다이얼로그(실제 창 렌더 → 통과)로 간다. + // macOS/Windows는 OSR이 정상이라 팝업 없이 자동 처리를 먼저 시도. + val osrProblem = if (isLinux) null else CodeforcesJcefFetcher.fetchProblem(id) + osrProblem + ?: fetchCodeforcesViaDialog(id) + ?: throw IllegalStateException(I18n.t( + "Codeforces 문제를 가져오지 못했습니다. Cloudflare 확인이 필요합니다.\n" + + "다시 시도하면 뜨는 창에서 확인을 완료해 주세요.", + "Failed to fetch the Codeforces problem — Cloudflare verification is required.\n" + + "Try again and complete the check in the window that appears." + )) } ProblemSource.SWEA -> throw IllegalStateException("unreachable") } @@ -831,6 +843,23 @@ class ProblemPanel(private val project: Project) : JPanel(BorderLayout()) { } } + /** + * OSR 폴백까지 실패했을 때의 최종 폴백: 보이는 다이얼로그로 사용자가 직접 + * Cloudflare 챌린지를 통과하게 한다 (이슈 #30). 백그라운드 스레드에서 호출되므로 + * EDT에서 다이얼로그를 띄우고 결과를 기다린다. + */ + private fun fetchCodeforcesViaDialog(id: String): Problem? { + if (!JBCefApp.isSupported()) return null + var result: Problem? = null + SwingUtilities.invokeAndWait { + val dialog = CodeforcesFetchDialog(project, id) + if (dialog.showAndGet()) { + result = dialog.getProblem() + } + } + return result + } + private fun fetchSweaProblem(problemId: String, language: Language) { fetchButton.isEnabled = false fetchButton.text = I18n.t("가져오는 중...", "Fetching...") diff --git a/src/main/kotlin/com/codingtestkit/ui/SettingsPanel.kt b/src/main/kotlin/com/codingtestkit/ui/SettingsPanel.kt index 7f29d2e..795e9ae 100644 --- a/src/main/kotlin/com/codingtestkit/ui/SettingsPanel.kt +++ b/src/main/kotlin/com/codingtestkit/ui/SettingsPanel.kt @@ -65,20 +65,9 @@ class SettingsPanel(private val project: Project) : JPanel() { font = font.deriveFont(Font.BOLD, JBUI.scaleFontSize(11f).toFloat()) }) langPanel.add(langCombo) - val langNotePanel = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - alignmentX = LEFT_ALIGNMENT - isOpaque = false - } - langNotePanel.add(JLabel(I18n.t("* 변경 후 도구 창을 다시 열어야 적용됩니다", "* Reopen tool window to apply changes")).apply { - font = font.deriveFont(JBUI.scaleFontSize(10f).toFloat()) - foreground = JBColor.GRAY - alignmentX = LEFT_ALIGNMENT - }) langSection.add(langPanel) - langSection.add(Box.createVerticalStrut(JBUI.scale(2))) - langSection.add(langNotePanel) langCombo.addActionListener { + // 언어 변경 시 도구 창이 즉시 다시 그려짐 (MainPanel의 I18n 리스너) I18n.setLanguage(I18n.Lang.entries[langCombo.selectedIndex]) } add(langSection) diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index f21e289..e480fed 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -1,7 +1,7 @@ com.codingtestkit CodingTestKit - 1.6.0 + 1.6.1 Su Beom v1.6.1 +

Codeforces fetching on Linux + instant UI language switching.

+ + English +
    +
  • [Fix] Codeforces fetch on Linux – The invisible (offscreen) browser cannot complete Cloudflare's canvas-rendering challenge on Linux due to a CEF-level offscreen paint/requestAnimationFrame issue (reproduced in CI). On Linux, fetching now goes straight to the visible fallback dialog (a real window renders correctly and passes the check); the passed cf_clearance cookie is cached so only the first problem shows the dialog and later fetches are silent. macOS/Windows keep the invisible automatic path.
  • +
  • [New] Instant UI language switching – Changing the UI language (Korean/English) in Settings now updates all panels immediately without reopening the tool window; the current tab is preserved.
  • +
+ + 한국어 +
    +
  • [수정] 리눅스 Codeforces 가져오기 – 안 보이는(오프스크린) 브라우저가 리눅스에서 CEF 오프스크린 페인트/requestAnimationFrame 문제로 Cloudflare의 canvas 렌더 챌린지를 통과하지 못하는 것을 CI로 확인. 리눅스는 이제 곧바로 보이는 폴백 다이얼로그(실제 창은 정상 렌더 → 통과)로 가며, 통과한 cf_clearance 쿠키를 캐싱해 첫 문제만 다이얼로그가 뜨고 이후는 조용히 가져온다. macOS/Windows는 안 보이는 자동 경로 유지.
  • +
  • [신규] UI 언어 즉시 전환 – 설정에서 UI 언어(한국어/English)를 바꾸면 도구 창을 다시 열지 않아도 모든 패널이 즉시 갱신된다. 보던 탭은 유지.
  • +
+

v1.6.0

Always-visible timer (Problems-tab mini bar + status bar) and Codeforces stability — invisible Cloudflare fallback, cookie-based fast fetching, math rendering & translation fixes.