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
4 changes: 2 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ plugins {
}

group = "com.codingtestkit"
version = "1.6.0"
version = "1.6.1"

repositories {
mavenCentral()
Expand Down Expand Up @@ -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 }
Expand Down
72 changes: 72 additions & 0 deletions docs/dev/linux-jcef-test.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added docs/screenshots/boj-login.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/boj-submit-code.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/boj-submit-confirm.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/boj-submit-success.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 27 additions & 0 deletions scripts/force-cf-fallback.sh
Original file line number Diff line number Diff line change
@@ -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
96 changes: 77 additions & 19 deletions src/main/kotlin/com/codingtestkit/service/CodeforcesJcefFetcher.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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("")
Expand All @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions src/main/kotlin/com/codingtestkit/service/I18n.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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() } }
}

/** 현재 언어에 따라 한국어/영어 문자열 반환 */
Expand Down
Loading
Loading