Compare commits

..

9 Commits

Author SHA1 Message Date
RyuJongJin 1b3160939c 버전 2026.10.20_001
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:22:46 +09:00
RyuJongJin 5366bb3a14 원격 시험 흔들림: "주소에 비밀번호 없음" 이 무작위 표에 걸리던 것 · FTP = rsync 비교 실패 때 자세히 (관리자 지시 10/20, 28 이 찾음)
- 원인 (28 의 hwp2 전체 시험 원문): 실패한 것은 "FTP 맞추기 결과 = 실제 rsync 결과" 가 아니라 다음 시험 "FTP 동영상 … 다리로 재생".
  시험 비밀번호가 두 글자 "pw" 인데 다리 주소의 무작위 표 (…-4pwvUR-…) 에 우연히 들어가 isNot(contains('pw')) 가 실패
  (대략 1% 안팎). 비밀번호가 샌 것이 아님 - 앱은 고치지 않음
- ftp · sftp_stream · smb 시험: 길고 드문 비밀번호로 보고, 주소의 사용자 정보 (userinfo) 가 비었는지도 따로
- ftp_test "FTP 맞추기 결과 = 실제 rsync 결과": 실패하면 옵션 · 걸린 시간 · 포트 · 예외 · 다른 점과 보내는 쪽 · 맞추기 전 앱 쪽 ·
  rsync 쪽 · 앱 쪽 목록 (크기 · 바뀐 시각 ms) 을 한 번에. 맞추기가 예외를 던져도 어느 옵션인지 남김
- 확인: 혼자 12번 · 전체 단위 시험 (1143 통과) · 원격 5개 파일 -j 8 로 4번 모두 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:21:07 +09:00
RyuJongJin ab4c82d0b0 WIP 판 번호 검사 (관리자 지시 10/20, 15 제안): check_apk - versionCode = 판 이름 날짜 (261018) × 1000 + 순번, 시험판은 순번 >= 900 (987 을 --build-number 261018987 로 빌드해 522036987 이 통과하던 것) · check_windows_build - 시험판 번호의 날짜는 판 이름 날짜 (빌드한 날 아님, d3 판단: APK 와 같은 번호 · compareVersions 가 + 뒤 번호 전체를 견줘 261020985 가 261018987 보다 새것으로 보임) · 확인: 987 통과, 985 · 988 Windows (261020xxx) 실패, 정식 판 001 -Release 통과 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:48:48 +09:00
RyuJongJin 3d3ab5666f 통합 회귀 고침: 시험이 옛 글 · 정해진 시간 · 빗나간 누르기로 실패하던 것과 남긴 임시 파일 (관리자 지시 10/20)
- rsync_page: rsync_narrow (dfed3fa) 부터 옵션 이름은 줄바꿈되지 않게 묶여 그려져
  '원본 파일 지우기 (--remove-source-files)' 를 글 그대로 찾지 못함 → keepOptionsTogether 로 화면 글 그대로 찾기
- windows_b6_input (82 끌어 놓기 → [되돌리기]): 스낵바가 아래에서 올라오는 동안 (글은 이미 있음) 눌러 빗나감
  (hit test 에 앱 위젯이 하나도 없음) → 되돌리기가 시작되지 않아 이동 확인 창이 안 나옴.
  누르기 전에 정말 눌리는 자리에 올 때까지 기다림 (tapReady) - 실패하면 캡처 (JJ_SHOT_DIR) · 창 · 단추 · 글 목록.
  측정: [되돌리기] 는 보인 뒤 1 ~ 97ms 지나야 눌림. 985 (f947935) 의 같은 시험도 3번 중 2번 똑같이 실패 → rsync_narrow 이전부터,
  앱 쪽 바뀜 없음 (스낵바 · 되돌리기 코드는 985 와 같음). 사용자는 보이는 단추를 누르므로 앱은 고치지 않음
- windows_xplore_shot: 목록을 정해진 1.2초만 기다려 en 412 가 한 번 실패 → 'Show S01' 줄이 올 때까지 (최대 약 10초)
- video_adjust_e2e · windows_xplore_shot: 앱 임시 폴더 jj_mkvmaker 를 시험 폴더로 (appTempInto)
- shell: 끝까지 돌면 단계 기록 jj_shell_steps.txt 를 지움 (앱이 도중에 꺼졌을 때만 남음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:08:44 +09:00
RyuJongJin 44c69af83e 통합 시험 종료 코드: 다 통과했는데 0 이 아니던 것 (ai_e2e · browser_keep · dns_block_real) 고침 (관리자 지시 10/20)
- ai_e2e: 앱 임시 폴더 (jj_mkvmaker) 가 실행 폴더에 남아 exit 2 → 새 도우미 appTempInto 로 시험 폴더 아래에 두고
  시험 끝에 기준을 되돌린 뒤 함께 지움 (ai_cancel · ai_long 도 같게). 사용자 %TEMP%\jj_mkvmaker 는 건드리지 않음
- dns_block_real (· cef_dns_real · tor_dns_real): Edge (WebView2) 사용자 데이터 폴더는 heldByApp (jj_held_)
- tool/test_temp_check.ps1: 실행 폴더 지우기를 Edge 가 놓을 때까지 2초 간격 최대 30번 다시 해 봄,
  rd 오류는 cmd 안에서 버림 (PowerShell 의 2>$null 이 ErrorActionPreference Stop 에서 오류가 되어 exit 1 이었음).
  지우는 것은 그 실행 폴더 안뿐
- 확인: 다섯 파일 모두 -d windows · test_temp_check 로 exit 0, 남은 것 0, 실행 폴더 지워짐. analyze 문제 없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:46:21 +09:00
RyuJongJin 9e40b485a9 rc 회귀 고침: 네트워크 공유 (UNC) 목록 · 기록 파일은 \\ 그대로 · 받는 쪽 UNC 도 \\ · 막히면 모니터링 [▶ 실행] 도 끔 · 표준 입력 (-) 문구 (검토 대기)
관리자 지시 (10/20, 28 이 f43f067 에서 확인 - 지운 것은 없음):
1. 회귀: ed459a0 이 목록 · 기록 · 배치 파일의 \\localhost\C$\… 를 //localhost/C$/… 로 바꿔 넘겨 rsync 가 "failed to open exclude
   file" (exit 11) 로 멈췄다 (985 는 \\ 그대로라 됨). 15 가 앱이 실제로 띄우는 방식 (응답 파일 · MSYS=noglob, rsync 3.5.1) 으로 자리마다
   확인 - 28 의 측정과 같음: 원본 = // 만 됨 (\\ 는 exit 23), 받는 쪽 = \\ 만 됨 (// 는 change_dir 실패 exit 3), 목록 · 기록 파일 =
   \\ 만 됨 (// 는 exit 11 · 기록이 안 생김).
   - rsyncFileOptionArgs: UNC 는 \\ 로 (//서버 로 적은 것도 \\ 로 바꿈), 드라이브는 /cygdrive/… 그대로.
   - 받는 쪽: 새 rsyncDestPath - UNC 면 \\서버\… (rsyncArgs 의 대상 · 실시간 동기화 rsyncArgsFor 의 받는 쪽). 원본은 toCygwinPath (//) 그대로.
     ed459a0 전부터 Windows 네트워크 공유가 받는 쪽인 rsync 는 실패했다 (관리자 판단: 데이터 문제는 아니지만 사용자가 겪는 실패라 rc 에).
2. 모니터링 복사 · rsync 카드: 옵션이 막혀 빨간 안내가 보이면 [▶ 실행] 도 끈다 (확인 창과 같게 - 누르면 실패로 끝났다).
3. --files-from=- · --exclude-from=- (표준 입력) 은 따로 된 문구 "표준 입력 (-) 은 쓸 수 없습니다" · 끝 문구 "목록 파일의 전체 경로를
   적어 주세요" (예전에는 "적어 주세요: - - C:\ …" 로 하이픈이 겹침).
시험: rsync_msys_args_test (실제 rsync - 관리 공유 \\localhost\C$ 로 원본 · 받는 쪽 UNC 와 목록 파일 \\ · // 를 복사 작업으로, 받는 쪽 UNC
실시간 동기화 쌍 · 바꿔 넘기기 단위 · 표준 입력 문구) · monitor_test (막히면 [실행] 꺼짐 · 고치면 켜짐).
전체 시험 1142 통과 · 1 실패 (explorer_test "여러 개 표시 → 삭제 · 새 폴더 · 이름 변경" - 이번 고침과 상관없음, 혼자 두 번 통과 ·
ed459a0 의 전체 시험에서도 통과 - 부하 흔들림) · analyze 문제 없음 · l10n 빠진 번역 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:29:47 +09:00
RyuJongJin f43f067d4b 시험 흔들림: remote_idle_test - "조각이 계속 오면 실패하지 않음" 은 0.1초 간격 · 3초 제한, SFTP 의 준비 (연결 · 목록 · 열기) 는 10초 (검토 대기)
0b 지적 (10/20): next10 전체 시험 (10분 넘음) 의 부하에서 0.4초 간격 대 1초 제한이 한 번 넘어 "서버가 답하지 않습니다 (1초)" 로 실패 (혼자서는 통과) - 여유를 30배로. 15 검증 (wt_verify_d3): 시험 다섯 파일을 함께 돌릴 때 SFTP 멈춤 시험의 준비 단계 (아직 멈추지 않은 list) 가 paramiko 의 답이 늦어 1초에 걸림 - 준비는 10초로 두고 멈추게 하기 바로 전에 1초로. 끊기는 쪽 확인은 그대로 1초. 코드 변경 없음.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:31:22 +09:00
RyuJongJin ed459a0229 rc: 목록 · 기록 파일의 C:/ · C:\ · UNC 를 rsync 가 여는 꼴로 넘김 · Windows 는 드라이브 경로만 · 막힐 때는 이유 한 줄만 · ---delete 문구 (검토 대기)
관리자 지시 (10/20, 28 이 985 에서 확인):
1. --exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · merge · --log-file · --write-batch · --read-batch ·
   --only-write-batch 의 값이 C:/… 이면 앱은 통과시키는데 rsync (MSYS2) 가 "failed to open exclude file" (exit 11) 로 실패했다
   (지운 것은 없음). 관리자 판단: 막지 말고 바르게 넘긴다 - rsyncFileOptionArgs 가 그 값의 C:\ · C:/ · UNC 를 toCygwinPath 로
   (rsyncArgs · 실시간 동기화 rsyncArgsFor). 패턴 · ": 이름" · 받는 쪽 기준 옵션 (--backup-dir 등) · -e 의 값은 그대로.
2. Windows 는 드라이브 (C:\ · C:/) · UNC · /cygdrive/ 만 전체 경로 - 그 밖의 / 는 MSYS 가 자기 설치 폴더 기준으로 읽어 막는다.
   끝 문구: Windows "C:\ 처럼 드라이브로 시작하는 전체 경로로", Android "/ 로 시작하는 전체 경로로".
3. [실행] 이 막힐 때 (알아볼 수 없음 · 상대 경로 등) 는 "미리 볼 수 없습니다" 줄과 [알고 실행합니다] 체크를 숨기고 막히는
   이유 한 줄만 (Rsync 화면 확인 창 · 지우기 포함 실행 확인 창).
4. "원본 파일 지우기 (--remove-source-files)" 가 412 ko · ja 에서 옵션 중간에 갈리던 것: rc 에 든 dfed3fa (keepOptionsTogether)
   로 이미 고쳐짐 - 캡처 shots_rsync_narrow (ko · ja · en 412) 로 확인.
5. "---delete" 의 끝 문구는 "옵션은 - 나 -- 로 시작합니다".
시험: rsync_msys_args_test (실제 rsync - C:/ · C:\ · 다음 낱말 · merge · -f. 로 적은 목록의 keep.txt 를 지킴, --log-file=C:/… 에
기록이 생김 · 바꿔 넘기기 단위 · Windows 의 / 막기 · 끝 문구) · dismiss_test (막힐 때 미리 볼 수 없음 · 체크 숨김) ·
option_paste_test (---delete 문구). 전체 시험 1140개 통과 (17 건너뜀) · analyze 문제 없음 · l10n 빠진 번역 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:24:35 +09:00
RyuJongJin 86b8ddd603 rc_next: next9 f947935 (985) + rsync_narrow dfed3fa (좁은 Rsync 화면 · 옵션 이름 줄바꿈)
다음 정식 판 후보 (관리자 지시 10/20). 겹친 곳: copy_sync_settings.dart 의 sync_preview_view import (optionProblemText + keepOptionsTogether 둘 다).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:42:20 +09:00
49 changed files with 485 additions and 15624 deletions

View File

@ -67,12 +67,9 @@ android {
sourceSets["main"].jniLibs.srcDir("../../third_party/tor/android")
// 86: 7-Zip 25.01 (lib7zz.so 라는 이름의 실행 파일 - 7z · RAR 풀기): tool/build_7zz_android.sh (공식 소스, SHA-256 고정)
sourceSets["main"].jniLibs.srcDir("../../third_party/7zip/android")
// 관리자 지시 (10/20): HWP · HWPX → PDF (libjj_rhwp.so - dart:ffi 로 읽는 라이브러리): tool/build_rhwp_android.sh (rhwp v0.8.7, 커밋 고정)
sourceSets["main"].jniLibs.srcDir("../../third_party/rhwp/android")
// 195: third_party 의 실행 파일은 third_party_libs.txt 에 적은 것만 (공유 폴더에 다른 판이 있어도 섞이지 않게)
val thirdPartyLibs = file("third_party_libs.txt").readLines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }.toSet()
listOf("../../third_party/rsync/android", "../../third_party/sdcpp/android", "../../third_party/tor/android", "../../third_party/7zip/android",
"../../third_party/rhwp/android").forEach { dir ->
listOf("../../third_party/rsync/android", "../../third_party/sdcpp/android", "../../third_party/tor/android", "../../third_party/7zip/android").forEach { dir ->
fileTree(dir).matching { include("**/*.so") }.forEach { so ->
if (so.name !in thirdPartyLibs) packaging.jniLibs.excludes += "**/${so.name}"
}

View File

@ -7,4 +7,3 @@ libsdcli.so
libsdcli_v80.so
libtor.so
lib7zz.so
libjj_rhwp.so

View File

@ -73,6 +73,7 @@
"- 감지한 언어": "- detected",
"-1 은 아무렇게나": "-1 = random",
".{0} 재생 프로그램 (exe)": ".{0} player (exe)",
"/ 로 시작하는 전체 경로로": "use a full path starting with /",
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = unlimited. Applies to every method: rsync --bwlimit, the built-in method throttles itself, robocopy approximates with /IPG. You can also put it in the options field (that takes priority)",
"0 으로": "Reset to 0",
"1/2 · AI 그림으로 다듬는 중": "1/2 · Refining with AI image",
@ -153,6 +154,7 @@
"Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "Restored previous settings from Android Auto Backup ({0} videos · {1} WebDAV servers)",
"Android: Tor 화면이 열려 있는 동안": "Android: while the Tor screen is open",
"BPE 토크나이저가 아닙니다: {0}": "Not a BPE tokenizer: {0}",
"C:\\ 처럼 드라이브로 시작하는 전체 경로로": "use a full path starting with a drive, like C:\\",
"Chrome (내려받기)": "Chrome (download)",
"Chrome (내장)": "Chrome (built-in)",
"Chrome 에서 가져오기": "Import from Chrome",
@ -211,7 +213,6 @@
"GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "Compares with the latest version on GitHub and downloads and installs it if newer",
"HTML 파일로 내보내기": "Export to HTML file",
"HTML 파일에서 가져오기": "Import from HTML file",
"HWP · HWPX 문서로 읽을 수 없습니다 (깨졌거나 지원하지 않는 형식)": "Can't read it as an HWP / HWPX document (damaged or unsupported format)",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.4.4)": "Enter an IPv4 or IPv6 address (e.g. 8.8.4.4)",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.8.8)": "Enter an IPv4 or IPv6 address (e.g. 8.8.8.8)",
"JJ_MKVMaker - 다운로드 {0}개": "JJ_MKVMaker - {0} downloads",
@ -546,7 +547,6 @@
"{0}: 작업 중이라 옮기지 않았습니다": "{0}: not moved because it is being processed",
"{0}: 저장하지 않았습니다": "{0}: not saved",
"{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: moves the {1} checked videos to {2}",
"{0}~{1}쪽을 {2}초 안에 바꾸지 못했습니다": "Couldn't convert pages {0}-{1} within {2} s",
"{0}개": "{0}",
"{0}개 ZIP 으로 묶기": "Compress {0} items to ZIP",
"{0}개 · {1}": "{0} · {1}",
@ -807,7 +807,6 @@
"그림을 읽을 수 없습니다": "Cannot read the image",
"그림을 페이지로 넣기 (이 페이지 뒤)": "Add images as pages (after this page)",
"그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "Almost the same image, and the last step is about 10× faster (when downloaded)",
"그만두었습니다": "Stopped",
"그중 {0}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다": "{0} of them are newer on the receiving side - the newer files will be overwritten with older ones",
"그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께": "Including {0} folders - everything inside them too",
"글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "Text → image · image → image (on this device; download models in Settings)",
@ -1027,7 +1026,6 @@
"단축키 - Total Commander: F5 복사 · F6 이동 · F7 새 폴더 · F8 지우기 · Insert/Space 표시 · Ctrl+R 새로 고침. 그 밖: Ctrl+C · X · V (복사 · 잘라내기 · 붙여넣기) · Ctrl+A 모두 표시 · F5 새로 고침": "Shortcuts - Total Commander: F5 copy · F6 move · F7 new folder · F8 delete · Insert/Space mark · Ctrl+R refresh. Others: Ctrl+C · X · V (copy · cut · paste) · Ctrl+A mark all · F5 refresh",
"단축키 해제": "Unregister hotkey",
"닫기": "Close",
"닫힌 문서입니다": "The document is closed",
"닫힘 (실행 안 함)": "Closed (not run)",
"담아 두고 붙여넣기 (기본)": "Hold and paste (default)",
"답이 없습니다": "No answer",
@ -1131,7 +1129,6 @@
"뒤로": "Back",
"뒤로 보내고 계속": "Keep running in background",
"뒤에서 계속": "Continue in background",
"드라이브 (C:) 나 / 로 시작하는 경로로": "Use a path that starts with a drive (C:) or /",
"드라이브 경로": "Drive path",
"드라이브의 맨 위 폴더는 지울 수 없습니다": "The top folder of a drive can't be deleted",
"등록": "Register",
@ -1248,6 +1245,7 @@
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "Hides ad areas in lists · next to · on top of videos",
"목록 보기": "View list",
"목록 파일은 전체 경로로 적어 주세요: {0}": "Please write the full path of the list file: {0}",
"목록 파일의 전체 경로를 적어 주세요": "write the full path of the list file",
"목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "If JJ_MKVMaker is not in the list, use [Allow another app] to pick JJ_MKVMaker.exe and check \"Private\". Turn sharing off and on again afterwards.",
"목록에서 동영상을 누르면 여기에 자세히 보입니다": "Tap a video in the list to see its details here",
"목록에서 빼기": "Remove from list",
@ -1297,7 +1295,6 @@
"바꾸기 ({0}개)": "Rename ({0})",
"바꾸는 정도 {0}%": "Change amount {0}%",
"바꾸면 바로 저장됩니다": "Saved automatically",
"바꾼 PDF 를 저장하지 못했습니다 (저장 공간을 확인해 주세요)": "Couldn't save the converted PDF (check storage space)",
"바꾼 것이 없습니다 (원본 그대로)": "Nothing changed (original as is)",
"바꿀 글": "Replace with",
"바꿀 글 (비우면 지움)": "Replace with (empty = remove)",
@ -1400,7 +1397,6 @@
"벵골어": "Bengali",
"변경 내용을 저장하지 않고 나갈까요?": "Leave without saving the changes?",
"변환": "Convert",
"변환기 안에서 오류가 났습니다": "An error occurred inside the converter",
"보관한 설정으로 되살리고 다시 시작": "Restore kept settings and restart",
"보관해 둔 설정을 되살렸습니다: {0}": "Restored the kept settings: {0}",
"보기": "View",
@ -1760,8 +1756,6 @@
"알리지 않음": "Don't notify",
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "Notifications show as JJ_MKVMaker and open the app when clicked (current user only, no admin rights)",
"암호 ZIP 으로 묶는 중 · {0}": "Making password ZIP · {0}",
"암호가 걸린 문서입니다": "This document is password-protected",
"암호가 맞지 않습니다": "The password is incorrect",
"암호로 묶기": "Make password ZIP",
"암호화": "Encryption",
"암호화 (TLS) 연결을 맺지 못했습니다: {0}": "Couldn't establish an encrypted (TLS) connection: {0}",
@ -1794,7 +1788,6 @@
"앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. 사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.": "Deletes the in-app browser's cookies (logins), site data, cache and history, plus the cookie file exported for downloads. You'll need to log in to sites again. Bookmarks are kept.",
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "In-app web browser · bookmarks · video downloads",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "In-app web browser: presses [Skip] for you, and mutes and fast-forwards ads that can't be skipped (\"Skipping ad…\" shows at the top while skipping)",
"앱 안에서 열지 못해 다른 앱으로 엽니다: {0}": "Couldn't open it in the app, opening with another app: {0}",
"앱 지우기": "Uninstall app",
"앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "Kept in the app trash for {0} days, then deleted (restore from [Trash] in the explorer).",
"앱에 들어 있는 것 (기본)": "Built into the app (default)",
@ -1834,7 +1827,6 @@
"엔진": "Engine",
"여기까지 범위 고르기": "Select range up to here",
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "Drop here to add to the Make MKV video list",
"여는 중: {0}": "Opening: {0}",
"여러 개를 골랐을 때": "When several are selected",
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "How many to process at once when making several videos into MKV (re-encoding uses a lot of CPU)",
"여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "Too many wrong tries. You can try again in {0} s",
@ -2079,7 +2071,6 @@
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "This location (network drive, network share, USB memory etc.) has no Recycle Bin, so items are deleted right away.",
"이 이동 버튼 지우기": "Delete this move button",
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "These options will be used the next time this move runs. Run it now with the new options?",
"이 쪽을 그리지 못했습니다": "Couldn't render this page",
"이 파일에 맞는 자막 우선 (영상 해시)": "Prefer subtitles that match this file (video hash)",
"이 파일용": "For this file",
"이 파일을 열 수 있는 앱이 없습니다.": "No app can open this file.",
@ -2459,7 +2450,6 @@
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "The last run did not end normally (last check {0}:{1}). It has been noted in the job log.",
"지난 업데이트가 남긴 임시 파일을 지움: {0}": "Removed temporary files left by the last update: {0}",
"지난 작업 기록": "Old job logs",
"지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "Removed {0} leftover document-view temp folders",
"지문 확인하고 연결": "Check fingerprint and connect",
"지우고 맞추기": "Delete and sync",
"지우고 초기화": "Delete and reset",
@ -2501,7 +2491,6 @@
"진행 패널 보기": "Show progress panel",
"집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "If it uses a self-signed certificate (like a home NAS), turn on \"Don't verify certificate\" in [Edit server settings].",
"짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "Shorter means faster syncing but more battery use.",
"쪽 범위가 맞지 않습니다": "The page range is invalid",
"차례 기다림": "Waiting in queue",
"창": "Panes",
"창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "Icon of the window, taskbar, tray and the top-left button in the app (the program file and desktop shortcut icons stay the same)",
@ -2669,7 +2658,6 @@
"파일만 골랐을 때": "When only files are selected",
"파일을 복사하지 못했습니다": "Could not copy the file",
"파일을 분석할 수 없습니다: {0}": "Cannot analyze the file: {0}",
"파일을 읽을 수 없습니다": "Can't read the file",
"파일을 읽지 못했습니다": "Could not read the file",
"파일이 깨졌습니다": "The file is damaged",
"파일이 깨졌습니다 · [다시 받기] 를 눌러 주세요": "The file is damaged · tap [Download again]",
@ -2744,6 +2732,7 @@
"표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "You can edit display names right here. Pressing a video row three times moves it to the first button's folder",
"표시 지우기": "Clear marks",
"표준": "Standard",
"표준 입력 (-) 은 쓸 수 없습니다": "Standard input (-) cannot be used",
"푸는 중": "Extracting",
"푸는 중: {0}": "Extracting: {0}",
"풀 수 없는 이름 {0}개": "{0} invalid names",

View File

@ -73,6 +73,7 @@
"- 감지한 언어": "- 検出した言語",
"-1 은 아무렇게나": "-1 はランダム",
".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)",
"/ 로 시작하는 전체 경로로": "/ で始まるフルパスで",
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 制限なし。すべての方法に適用: rsync は --bwlimit、現在の方式はアプリが調整、robocopy は /IPG で近似。オプション欄に直接入れても構いません (そちらが優先)",
"0 으로": "0 に戻す",
"1/2 · AI 그림으로 다듬는 중": "1/2 · AI 画像で整え中",
@ -153,6 +154,7 @@
"Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "Android 自動バックアップから以前の設定を復元しました (動画リスト {0} 件・WebDAV サーバー {1} 件)",
"Android: Tor 화면이 열려 있는 동안": "Android: Tor 画面を開いている間",
"BPE 토크나이저가 아닙니다: {0}": "BPE トークナイザーではありません: {0}",
"C:\\ 처럼 드라이브로 시작하는 전체 경로로": "C:\\ のようにドライブで始まるフルパスで",
"Chrome (내려받기)": "Chrome(ダウンロード)",
"Chrome (내장)": "Chrome(内蔵)",
"Chrome 에서 가져오기": "Chrome から取り込む",
@ -211,7 +213,6 @@
"GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "GitHub の最新版と比べ、新しい版があればダウンロードしてインストールします",
"HTML 파일로 내보내기": "HTML ファイルに書き出す",
"HTML 파일에서 가져오기": "HTML ファイルから取り込む",
"HWP · HWPX 문서로 읽을 수 없습니다 (깨졌거나 지원하지 않는 형식)": "HWP・HWPX 文書として読み込めません (破損しているか未対応の形式)",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.4.4)": "IPv4・IPv6 アドレスを入力してください (例: 8.8.4.4)",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.8.8)": "IPv4・IPv6 アドレスを入力してください (例: 8.8.8.8)",
"JJ_MKVMaker - 다운로드 {0}개": "JJ_MKVMaker - ダウンロード {0}個",
@ -546,7 +547,6 @@
"{0}: 작업 중이라 옮기지 않았습니다": "{0}: 作業中のため移動しませんでした",
"{0}: 저장하지 않았습니다": "{0}: 保存しませんでした",
"{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: チェックした動画 {1}個を {2} に移動します",
"{0}~{1}쪽을 {2}초 안에 바꾸지 못했습니다": "{0}~{1} ページを {2} 秒以内に変換できませんでした",
"{0}개": "{0}個",
"{0}개 ZIP 으로 묶기": "{0} 件を ZIP にまとめる",
"{0}개 · {1}": "{0}個・{1}",
@ -807,7 +807,6 @@
"그림을 읽을 수 없습니다": "画像を読み込めません",
"그림을 페이지로 넣기 (이 페이지 뒤)": "画像をページとして追加 (このページの後)",
"그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "画像はほぼ同じで、最後の段階が約 10 倍速くなります (ダウンロード済みの場合)",
"그만두었습니다": "中止しました",
"그중 {0}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다": "そのうち {0} 件は受け取る側の方が新しいです - 新しいファイルを古いもので上書きします",
"그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께": "うちフォルダー {0} 件 - 中のファイル・フォルダーもすべて一緒に",
"글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "テキスト → 画像 · 画像 → 画像 (端末内で。モデルは設定でダウンロード)",
@ -1027,7 +1026,6 @@
"단축키 - Total Commander: F5 복사 · F6 이동 · F7 새 폴더 · F8 지우기 · Insert/Space 표시 · Ctrl+R 새로 고침. 그 밖: Ctrl+C · X · V (복사 · 잘라내기 · 붙여넣기) · Ctrl+A 모두 표시 · F5 새로 고침": "ショートカット - Total Commander: F5 コピー · F6 移動 · F7 新しいフォルダー · F8 削除 · Insert/Space マーク · Ctrl+R 更新。その他: Ctrl+C · X · V (コピー · 切り取り · 貼り付け) · Ctrl+A すべてマーク · F5 更新",
"단축키 해제": "ショートカット解除",
"닫기": "閉じる",
"닫힌 문서입니다": "文書は閉じられています",
"닫힘 (실행 안 함)": "閉じた (実行しない)",
"담아 두고 붙여넣기 (기본)": "保持して貼り付け (既定)",
"답이 없습니다": "応答がありません",
@ -1131,7 +1129,6 @@
"뒤로": "戻る",
"뒤로 보내고 계속": "裏で続ける",
"뒤에서 계속": "バックグラウンドで続ける",
"드라이브 (C:) 나 / 로 시작하는 경로로": "ドライブ (C:) か / で始まるパスで",
"드라이브 경로": "ドライブのパス",
"드라이브의 맨 위 폴더는 지울 수 없습니다": "ドライブの最上位フォルダーは削除できません",
"등록": "登録",
@ -1248,6 +1245,7 @@
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "リスト・動画の横・動画の上に出る広告エリアを隠します",
"목록 보기": "リストを見る",
"목록 파일은 전체 경로로 적어 주세요: {0}": "リストファイルはフルパスで書いてください: {0}",
"목록 파일의 전체 경로를 적어 주세요": "リストファイルのフルパスを書いてください",
"목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "一覧に JJ_MKVMaker がなければ [別のアプリの許可] で JJ_MKVMaker.exe を選び「プライベート」にチェックします。変更後は共有をオフにして再びオンにしてください。",
"목록에서 동영상을 누르면 여기에 자세히 보입니다": "リストの動画をタップすると、ここに詳細が表示されます",
"목록에서 빼기": "リストから外す",
@ -1297,7 +1295,6 @@
"바꾸기 ({0}개)": "変更 ({0} 件)",
"바꾸는 정도 {0}%": "変える度合い {0}%",
"바꾸면 바로 저장됩니다": "変更するとすぐ保存されます",
"바꾼 PDF 를 저장하지 못했습니다 (저장 공간을 확인해 주세요)": "変換した PDF を保存できませんでした (空き容量を確認してください)",
"바꾼 것이 없습니다 (원본 그대로)": "変更なし(元のまま)",
"바꿀 글": "置き換える文字",
"바꿀 글 (비우면 지움)": "置き換える文字 (空なら削除)",
@ -1400,7 +1397,6 @@
"벵골어": "ベンガル語",
"변경 내용을 저장하지 않고 나갈까요?": "変更を保存せずに終了しますか?",
"변환": "変換",
"변환기 안에서 오류가 났습니다": "変換エンジン内でエラーが発生しました",
"보관한 설정으로 되살리고 다시 시작": "保管した設定で復元して再起動",
"보관해 둔 설정을 되살렸습니다: {0}": "保管した設定を復元しました: {0}",
"보기": "表示",
@ -1760,8 +1756,6 @@
"알리지 않음": "知らせない",
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)",
"암호 ZIP 으로 묶는 중 · {0}": "パスワード付き ZIP にまとめています · {0}",
"암호가 걸린 문서입니다": "パスワードで保護された文書です",
"암호가 맞지 않습니다": "パスワードが正しくありません",
"암호로 묶기": "パスワード付きでまとめる",
"암호화": "暗号化",
"암호화 (TLS) 연결을 맺지 못했습니다: {0}": "暗号化 (TLS) 接続を確立できませんでした: {0}",
@ -1794,7 +1788,6 @@
"앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. 사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.": "アプリ内ブラウザの Cookie (ログイン)・サイトデータ・キャッシュ・閲覧履歴と、ダウンロード用に書き出した Cookie ファイルを削除します。サイトへの再ログインが必要です。お気に入りはそのままです。",
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "アプリ内 Web ブラウザー・ブックマーク・動画ダウンロード",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)",
"앱 안에서 열지 못해 다른 앱으로 엽니다: {0}": "アプリ内で開けないため、他のアプリで開きます: {0}",
"앱 지우기": "アプリを削除",
"앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "アプリのゴミ箱に {0} 日間置いてから削除します (エクスプローラーの [ゴミ箱] から復元できます)。",
"앱에 들어 있는 것 (기본)": "アプリに内蔵 (既定)",
@ -1834,7 +1827,6 @@
"엔진": "エンジン",
"여기까지 범위 고르기": "ここまで範囲選択",
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "ここにドロップすると MKV 作成の動画リストに追加します",
"여는 중: {0}": "開いています: {0}",
"여러 개를 골랐을 때": "複数選んだとき",
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "複数の動画を MKV にするとき同時に処理する数(再エンコードは CPU を多く使います)",
"여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "何度も間違えたため {0} 秒後に再入力できます",
@ -2079,7 +2071,6 @@
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "この場所 (ネットワークドライブ・ネットワーク共有・USB メモリなど) にはごみ箱がないため、すぐに削除されます。",
"이 이동 버튼 지우기": "この移動ボタンを削除",
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "この移動を次に行うときにこのオプションを使います。今すぐ新しいオプションで実行しますか?",
"이 쪽을 그리지 못했습니다": "このページを描画できませんでした",
"이 파일에 맞는 자막 우선 (영상 해시)": "このファイルに合う字幕を優先(動画ハッシュ)",
"이 파일용": "このファイル用",
"이 파일을 열 수 있는 앱이 없습니다.": "このファイルを開けるアプリがありません。",
@ -2459,7 +2450,6 @@
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "前回の実行が正常に終了しませんでした(最終確認 {0}:{1})。作業記録に残しました。",
"지난 업데이트가 남긴 임시 파일을 지움: {0}": "前回のアップデートが残した一時ファイルを削除: {0}",
"지난 작업 기록": "過去の作業記録",
"지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "前回残った文書表示の一時フォルダーを削除: {0} 個",
"지문 확인하고 연결": "フィンガープリントを確認して接続",
"지우고 맞추기": "削除して同期",
"지우고 초기화": "削除して初期化",
@ -2501,7 +2491,6 @@
"진행 패널 보기": "進行パネルを表示",
"집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "自宅 NAS のように自己署名証明書なら、[サーバー設定を修正] で「証明書を検証しない」をオンにしてください。",
"짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "短いほど早く同期しますが、バッテリーをより使います。",
"쪽 범위가 맞지 않습니다": "ページ範囲が正しくありません",
"차례 기다림": "順番待ち",
"창": "ペイン",
"창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "ウィンドウ・タスクバー・トレイとアプリ内左上のボタンのアイコンです(実行ファイル・デスクトップのショートカットのアイコンはそのまま)",
@ -2669,7 +2658,6 @@
"파일만 골랐을 때": "ファイルだけを選んだとき",
"파일을 복사하지 못했습니다": "ファイルをコピーできませんでした",
"파일을 분석할 수 없습니다: {0}": "ファイルを分析できません: {0}",
"파일을 읽을 수 없습니다": "ファイルを読み込めません",
"파일을 읽지 못했습니다": "ファイルを読み取れませんでした",
"파일이 깨졌습니다": "ファイルが壊れています",
"파일이 깨졌습니다 · [다시 받기] 를 눌러 주세요": "ファイルが壊れています・[再ダウンロード] を押してください",
@ -2744,6 +2732,7 @@
"표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "表示名はその場で変更できます。動画の行を 3回押すと最初のボタンのフォルダーに移動します",
"표시 지우기": "マークを解除",
"표준": "標準",
"표준 입력 (-) 은 쓸 수 없습니다": "標準入力 (-) は使えません",
"푸는 중": "展開中",
"푸는 중: {0}": "展開中: {0}",
"풀 수 없는 이름 {0}개": "展開できない名前 {0} 件",

View File

@ -73,6 +73,7 @@
"- 감지한 언어": "- 检测到的语言",
"-1 은 아무렇게나": "-1 为随机",
".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)",
"/ 로 시작하는 전체 경로로": "请使用以 / 开头的完整路径",
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 不限制。适用于所有方式: rsync 用 --bwlimit,当前方式由应用调节,robocopy 用 /IPG 近似。也可直接写在选项栏中 (优先)",
"0 으로": "归零",
"1/2 · AI 그림으로 다듬는 중": "1/2 · 正在用 AI 图片优化",
@ -153,6 +154,7 @@
"Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "已从 Android 自动备份恢复之前的设置 (视频列表 {0} 个 · WebDAV 服务器 {1} 个)",
"Android: Tor 화면이 열려 있는 동안": "Android: Tor 画面打开期间",
"BPE 토크나이저가 아닙니다: {0}": "不是 BPE 分词器: {0}",
"C:\\ 처럼 드라이브로 시작하는 전체 경로로": "请使用以驱动器开头的完整路径,如 C:\\",
"Chrome (내려받기)": "Chrome(下载)",
"Chrome (내장)": "Chrome(内置)",
"Chrome 에서 가져오기": "从 Chrome 导入",
@ -211,7 +213,6 @@
"GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "与 GitHub 上的最新版本比较,如有新版本则下载并安装",
"HTML 파일로 내보내기": "导出为 HTML 文件",
"HTML 파일에서 가져오기": "从 HTML 文件导入",
"HWP · HWPX 문서로 읽을 수 없습니다 (깨졌거나 지원하지 않는 형식)": "无法作为 HWP · HWPX 文档读取 (已损坏或格式不受支持)",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.4.4)": "请输入 IPv4 · IPv6 地址 (例: 8.8.4.4)",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.8.8)": "请输入 IPv4 · IPv6 地址 (例: 8.8.8.8)",
"JJ_MKVMaker - 다운로드 {0}개": "JJ_MKVMaker - 下载 {0} 个",
@ -546,7 +547,6 @@
"{0}: 작업 중이라 옮기지 않았습니다": "{0}: 正在处理,未移动",
"{0}: 저장하지 않았습니다": "{0}:未保存",
"{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: 将勾选的 {1} 个视频移到 {2}",
"{0}~{1}쪽을 {2}초 안에 바꾸지 못했습니다": "无法在 {2} 秒内转换第 {0}~{1} 页",
"{0}개": "{0} 个",
"{0}개 ZIP 으로 묶기": "将 {0} 项压缩为 ZIP",
"{0}개 · {1}": "{0} 个 · {1}",
@ -807,7 +807,6 @@
"그림을 읽을 수 없습니다": "无法读取图片",
"그림을 페이지로 넣기 (이 페이지 뒤)": "将图片作为页面加入 (本页之后)",
"그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "图片几乎相同,最后一步快约 10 倍 (已下载时)",
"그만두었습니다": "已停止",
"그중 {0}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다": "其中 {0} 个在接收端较新 - 将用旧文件覆盖较新的文件",
"그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께": "其中 {0} 个文件夹 - 其中的文件和文件夹也一并处理",
"글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "文字 → 图片 · 图片 → 图片(在本设备上;模型在设置中下载)",
@ -1027,7 +1026,6 @@
"단축키 - Total Commander: F5 복사 · F6 이동 · F7 새 폴더 · F8 지우기 · Insert/Space 표시 · Ctrl+R 새로 고침. 그 밖: Ctrl+C · X · V (복사 · 잘라내기 · 붙여넣기) · Ctrl+A 모두 표시 · F5 새로 고침": "快捷键 - Total Commander: F5 复制 · F6 移动 · F7 新建文件夹 · F8 删除 · Insert/Space 标记 · Ctrl+R 刷新。其他: Ctrl+C · X · V (复制 · 剪切 · 粘贴) · Ctrl+A 全部标记 · F5 刷新",
"단축키 해제": "注销快捷键",
"닫기": "关闭",
"닫힌 문서입니다": "文档已关闭",
"닫힘 (실행 안 함)": "已关闭 (未执行)",
"담아 두고 붙여넣기 (기본)": "暂存后粘贴 (默认)",
"답이 없습니다": "没有应答",
@ -1131,7 +1129,6 @@
"뒤로": "后退",
"뒤로 보내고 계속": "转到后台继续",
"뒤에서 계속": "在后台继续",
"드라이브 (C:) 나 / 로 시작하는 경로로": "请使用以盘符 (C:) 或 / 开头的路径",
"드라이브 경로": "驱动器路径",
"드라이브의 맨 위 폴더는 지울 수 없습니다": "无法删除驱动器的根文件夹",
"등록": "注册",
@ -1248,6 +1245,7 @@
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "隐藏列表 · 视频旁 · 视频上方出现的广告区域",
"목록 보기": "查看列表",
"목록 파일은 전체 경로로 적어 주세요: {0}": "请填写列表文件的完整路径: {0}",
"목록 파일의 전체 경로를 적어 주세요": "请填写列表文件的完整路径",
"목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "如果列表中没有 JJ_MKVMaker,请用 [允许其他应用] 选择 JJ_MKVMaker.exe 并勾选“专用”。更改后请关闭共享再重新开启。",
"목록에서 동영상을 누르면 여기에 자세히 보입니다": "点按列表中的视频即可在此查看详情",
"목록에서 빼기": "从列表中移除",
@ -1297,7 +1295,6 @@
"바꾸기 ({0}개)": "重命名 ({0} 个)",
"바꾸는 정도 {0}%": "改变程度 {0}%",
"바꾸면 바로 저장됩니다": "更改后立即保存",
"바꾼 PDF 를 저장하지 못했습니다 (저장 공간을 확인해 주세요)": "无法保存转换后的 PDF (请检查存储空间)",
"바꾼 것이 없습니다 (원본 그대로)": "未做更改(保持原样)",
"바꿀 글": "替换为",
"바꿀 글 (비우면 지움)": "替换为 (留空则删除)",
@ -1400,7 +1397,6 @@
"벵골어": "孟加拉语",
"변경 내용을 저장하지 않고 나갈까요?": "不保存更改就离开吗?",
"변환": "转换",
"변환기 안에서 오류가 났습니다": "转换器内部出错",
"보관한 설정으로 되살리고 다시 시작": "用保存的设置恢复并重新启动",
"보관해 둔 설정을 되살렸습니다: {0}": "已恢复保存的设置: {0}",
"보기": "查看",
@ -1760,8 +1756,6 @@
"알리지 않음": "不提醒",
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)",
"암호 ZIP 으로 묶는 중 · {0}": "正在创建加密 ZIP · {0}",
"암호가 걸린 문서입니다": "这是受密码保护的文档",
"암호가 맞지 않습니다": "密码不正确",
"암호로 묶기": "加密压缩",
"암호화": "加密",
"암호화 (TLS) 연결을 맺지 못했습니다: {0}": "无法建立加密 (TLS) 连接:{0}",
@ -1794,7 +1788,6 @@
"앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. 사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.": "删除应用内浏览器的 Cookie (登录) · 网站数据 · 缓存 · 浏览记录,以及为下载导出的 Cookie 文件。需要重新登录网站。收藏保持不变。",
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "应用内浏览器 · 书签 · 视频下载",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)",
"앱 안에서 열지 못해 다른 앱으로 엽니다: {0}": "无法在应用内打开,改用其他应用打开: {0}",
"앱 지우기": "卸载应用",
"앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "在应用回收站保留 {0} 天后删除 (可在资源管理器的 [回收站] 中还原)。",
"앱에 들어 있는 것 (기본)": "应用内置 (默认)",
@ -1834,7 +1827,6 @@
"엔진": "引擎",
"여기까지 범위 고르기": "选择到此处的范围",
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "拖放到此处即可加入制作 MKV 的视频列表",
"여는 중: {0}": "正在打开: {0}",
"여러 개를 골랐을 때": "选择多个时",
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "将多个视频制作成 MKV 时同时处理的数量(重新编码会占用大量 CPU)",
"여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "错误次数过多,{0} 秒后可再次输入",
@ -2079,7 +2071,6 @@
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "此位置 (网络驱动器 · 网络共享 · U 盘等) 没有回收站,会直接删除。",
"이 이동 버튼 지우기": "删除此移动按钮",
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次执行此移动时将使用这些选项。现在用新选项执行吗?",
"이 쪽을 그리지 못했습니다": "无法渲染此页",
"이 파일에 맞는 자막 우선 (영상 해시)": "优先匹配此文件的字幕(视频哈希)",
"이 파일용": "适用于此文件",
"이 파일을 열 수 있는 앱이 없습니다.": "没有可以打开此文件的应用。",
@ -2459,7 +2450,6 @@
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "上次运行未正常结束(最后检查 {0}:{1})。已记录在任务记录中。",
"지난 업데이트가 남긴 임시 파일을 지움: {0}": "已删除上次更新留下的临时文件: {0}",
"지난 작업 기록": "旧任务记录",
"지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "已删除上次留下的文档查看临时文件夹: {0} 个",
"지문 확인하고 연결": "确认指纹并连接",
"지우고 맞추기": "删除并同步",
"지우고 초기화": "删除并重置",
@ -2501,7 +2491,6 @@
"진행 패널 보기": "显示进度面板",
"집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "如果是自签名证书 (如家用 NAS),请在 [修改服务器设置] 中开启“不验证证书”。",
"짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "间隔越短同步越快,但更耗电。",
"쪽 범위가 맞지 않습니다": "页面范围无效",
"차례 기다림": "排队等待",
"창": "窗格",
"창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "窗口、任务栏、托盘以及应用内左上角按钮的图标(程序文件和桌面快捷方式的图标不变)",
@ -2669,7 +2658,6 @@
"파일만 골랐을 때": "只选择文件时",
"파일을 복사하지 못했습니다": "无法复制文件",
"파일을 분석할 수 없습니다: {0}": "无法分析文件: {0}",
"파일을 읽을 수 없습니다": "无法读取文件",
"파일을 읽지 못했습니다": "无法读取文件",
"파일이 깨졌습니다": "文件已损坏",
"파일이 깨졌습니다 · [다시 받기] 를 눌러 주세요": "文件已损坏 · 请点按 [重新下载]",
@ -2744,6 +2732,7 @@
"표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "显示名称可直接修改。连续点击视频行三次会移到第一个按钮的文件夹",
"표시 지우기": "清除标记",
"표준": "标准",
"표준 입력 (-) 은 쓸 수 없습니다": "不能使用标准输入 (-)",
"푸는 중": "正在解压",
"푸는 중: {0}": "正在解压: {0}",
"풀 수 없는 이름 {0}개": "{0} 个无法解压的名称",

File diff suppressed because it is too large Load Diff

View File

@ -58,10 +58,6 @@ JJ_MKVMaker (Android) - 포함된 오픈 소스 구성 요소 및 라이선스
(이 앱은 RAR 을 만들지 않습니다). 공식 소스 (https://www.7-zip.org/a/7z2501-src.7z) 를 고치지 않고 Android NDK 로 빌드
(빌드 방법: 소스의 tool/build_7zz_android.sh). 따로 실행하는 프로그램이며, 대응 소스는 위 주소와 이 앱 판의 태그에 있습니다.
원문: 앱 안 "오픈 소스 라이선스" 의 7-Zip (7ZIP_LICENSE.txt) · GNU LGPL 2.1
- rhwp v0.8.7 (libjj_rhwp.so - HWP · HWPX 를 PDF 로 바꿔 보기) MIT https://github.com/edwardkim/rhwp
Copyright (c) 2025-2026 Edward Kim. 커밋 1a76570 을 고치지 않고, 얇은 C 함수 묶음 (소스의 tool/rhwp_ffi) 과 함께 Android NDK 로
빌드 (빌드 방법: 소스의 tool/build_rhwp_android.sh). 함께 묶인 Rust crate 들 (MIT · Apache-2.0 등) 의 목록과 원문:
앱 안 "오픈 소스 라이선스" 의 rhwp (RHWP_LICENSES.txt)
- stable-diffusion.cpp · ggml (libsdcli.so 라는 이름의 실행 파일) MIT https://github.com/leejet/stable-diffusion.cpp
커밋 228c707 을 고치지 않고 Android NDK 로 빌드 (AI 그림 · 해상도 올리기, 빌드 방법: 소스의 tool/build_sdcpp_android.sh)
함께 묶인 구성 요소: ggml (MIT) · libwebp · libwebm (BSD-3) · Oniguruma · Darts-clone (BSD-2) · utf8proc · nlohmann/json ·

View File

@ -7,6 +7,7 @@ import 'package:jj_mkvmaker/core/languages.dart';
import 'package:jj_mkvmaker/core/models.dart';
import 'package:jj_mkvmaker/services/model_store.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'support/temp_dir.dart';
/// 긴 영상 (JJ_LONG_VIDEO) 의 음성인식 도중 [작업 취소] → 몇 초 안에 멈춘다 (끝까지 기다리지 않음)
void main() {
@ -15,6 +16,7 @@ void main() {
testWidgets('음성인식 중 취소', (tester) async {
final video = Platform.environment['JJ_LONG_VIDEO'];
if (video == null || !File(video).existsSync()) return markTestSkipped('JJ_LONG_VIDEO 없음');
appTempInto(testTempDir('jj_aitmp_')); // 앱 임시 폴더 (jj_mkvmaker) 를 시험 폴더로 - 끝에 지움 (관리자 지시 10/20)
final c = AppController(PlatformServices.create());
await tester.runAsync(() async {
await c.init();

View File

@ -18,6 +18,7 @@ void main() {
testWidgets('영어 음성 영상 → AI 자막(ko/en/ja) → MKV', (tester) async {
final dir = testTempDir('jj_e2e_');
appTempInto(dir); // 앱 임시 폴더 (jj_mkvmaker) 도 이 아래로 - 끝에 함께 지움 (관리자 지시 10/20)
final video = p.join(dir.path, '영어 강의.mp4');
final speech = p.join(Directory.current.path, 'test_assets', 'speech_en.wav');
final r = await Process.run('ffmpeg', [

View File

@ -8,6 +8,7 @@ import 'package:jj_mkvmaker/core/models.dart';
import 'package:jj_mkvmaker/core/output_paths.dart';
import 'package:jj_mkvmaker/services/model_store.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'support/temp_dir.dart';
/// 긴 실제 영상 (JJ_LONG_VIDEO) 으로 AI 자막: 원어 자동 → [JJ_LONG_TARGETS] (기본 ja,en).
/// 번역이 제대로 됐는지 줄 단위로 센다 (영어 자막에 일본어가 남은 줄 · 빈 줄 · 같은 말 되풀이).
@ -18,6 +19,7 @@ void main() {
final video = Platform.environment['JJ_LONG_VIDEO'];
if (video == null || !File(video).existsSync()) return markTestSkipped('JJ_LONG_VIDEO 없음');
final targets = (Platform.environment['JJ_LONG_TARGETS'] ?? 'ja,en').split(',');
appTempInto(testTempDir('jj_aitmp_')); // 앱 임시 폴더 (jj_mkvmaker) 를 시험 폴더로 - 끝에 지움 (관리자 지시 10/20)
final c = AppController(PlatformServices.create());
final sw = Stopwatch()..start();
await tester.runAsync(() async {

View File

@ -1,145 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/core/hwp_source.dart';
import 'package:jj_mkvmaker/core/reader_sources.dart';
import 'package:path/path.dart' as p;
import 'package:pdfrx/pdfrx.dart';
import 'support/temp_dir.dart';
/// 쪽마다 그림 한 장인 진짜 PDF 를 만드는 가짜 엔진 (rhwp 대신) - 부른 범위를 적어 둔다
class _FakeEngine implements HwpPdfEngine {
final int pages;
final String tempDir;
final calls = <(int, int)>[];
var failNext = 0;
Completer<void>? hold;
_FakeEngine(this.pages, this.tempDir);
@override
Future<HwpPdfDoc> open(String path, {String? password}) async {
if (password == 'bad') throw const HwpPdfException(HwpErrorKind.passwordWrong, 'CryptoError WrongPassword');
return QueuedHwpDoc(_FakeNative(this));
}
}
/// 앱과 같은 [QueuedHwpDoc] 아래의 가짜 C 함수
class _FakeNative implements HwpNative {
final _FakeEngine e;
var closed = false;
_FakeNative(this.e);
@override
int get pageCount => e.pages;
@override
void cancel(int req) {
final h = e.hold;
if (h != null && !h.isCompleted) h.complete(); // Rust 처럼 다음 쪽에서 멈춤
}
@override
void cancelAll() => cancel(0);
@override
Future<(int, String?)> export(int req, int from, int to, String fontDirs, String out) async {
e.calls.add((from, to));
// 끝나지 않는 변환 (시간 제한 시험): 멈춤으로 풀리면 아무것도 쓰지 않는다 (시험이 끝난 뒤 폴더에 쓰지 않게)
if (e.hold != null) {
await e.hold!.future;
return (1, 'cancelled');
}
if (e.failNext > 0) {
e.failNext--;
return (7, 'page 1: boom');
}
final docs = <PdfDocument>[];
for (var i = from; i < to; i++) {
final png = p.join(e.tempDir, 'pg$i.png');
// 쪽마다 폭이 달라 (100 + 쪽 번호) 어느 쪽이 그려졌는지 크기로 안다
File(png).writeAsBytesSync(img.encodePng(img.Image(width: 100 + i, height: 140)));
docs.add(await imagePageDocument(png, tempDir: e.tempDir, sourceName: 'jj_fake_$i'));
}
final outDoc = await PdfDocument.createNew(sourceName: 'jj_fake_out_$from');
outDoc.pages = [for (final d in docs) d.pages.first];
File(out).writeAsBytesSync(await outDoc.encodePdf());
await outDoc.dispose();
for (final d in docs) {
await d.dispose();
}
return (0, null);
}
@override
Future<void> close() async => closed = true;
}
/// 관리자 지시 (10/20): HWP · HWPX 보기 - 보는 쪽의 묶음만 PDF 로 바꾸고 다음 묶음을 미리 만든다 · 시간 제한 · 실패하면 다시 · 닫기.
/// (rhwp 대신 가짜 엔진, PDF 를 그리는 PDFium 이 필요해 Windows 통합 시험)
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('보는 쪽의 묶음 (5쪽) 만 바꾸고 다음 묶음을 미리 · 끝 묶음은 남은 쪽만', (tester) async {
final dir = testTempDir('jj_hwpsrc_');
final doc = File(p.join(dir.path, 'a.hwp'))..writeAsStringSync('x');
final engine = _FakeEngine(12, dir.path);
final src = await tester.runAsync(() => HwpSource.open(doc.path, engine: engine, tempDir: dir.path, chunk: 5));
expect(src!.length, 12);
expect(src.pageName(0), '1 / 12');
final first = await tester.runAsync(() => src.load(0, maxWidth: 200));
expect(first!.image, isNotNull);
first.dispose();
await tester.runAsync(() => Future<void>.delayed(const Duration(seconds: 2)));
expect(engine.calls, [(0, 5), (5, 10)], reason: '첫 묶음 + 다음 묶음 미리');
// 끝 쪽: 남은 2쪽만 (11번째 = 폭 110)
final last = await tester.runAsync(() => src.load(11, maxWidth: 200));
expect(last!.image, isNotNull);
last.dispose();
expect(engine.calls.last, (10, 12));
// 이미 만든 묶음은 다시 만들지 않는다
final again = await tester.runAsync(() => src.load(3, maxWidth: 200));
again!.dispose();
expect(engine.calls.where((c) => c == (0, 5)), hasLength(1));
await tester.runAsync(src.dispose);
expect(Directory(src.work.path).existsSync(), isFalse, reason: '닫으면 임시 PDF 를 지움');
});
testWidgets('묶음이 실패하면 그 쪽에 이유, 다시 보면 다시 만든다 · 시간 제한 · 열기 실패', (tester) async {
final dir = testTempDir('jj_hwpsrc_');
final doc = File(p.join(dir.path, 'b.hwpx'))..writeAsStringSync('x');
final engine = _FakeEngine(3, dir.path)..failNext = 1;
final src = await tester.runAsync(() => HwpSource.open(doc.path, engine: engine, tempDir: dir.path, chunk: 5,
timeout: const Duration(seconds: 2)));
Object? err;
await tester.runAsync(() => src!.load(0, maxWidth: 200).then((r) => r.dispose(), onError: (Object e) {
err = e;
}));
expect('$err', contains('이 쪽을 그리지 못했습니다'));
final ok = await tester.runAsync(() => src!.load(0, maxWidth: 200));
expect(ok!.image, isNotNull, reason: '실패한 묶음은 다시');
ok.dispose();
await tester.runAsync(src!.dispose);
// 시간 제한: 끝나지 않는 변환
final slow = _FakeEngine(3, dir.path)..hold = Completer<void>();
final s2 = await tester.runAsync(() => HwpSource.open(doc.path, engine: slow, tempDir: dir.path, timeout: const Duration(seconds: 1)));
Object? err2;
await tester.runAsync(() => s2!.load(1, maxWidth: 200).then((r) => r.dispose(), onError: (Object e) {
err2 = e;
}));
expect(err2, isA<HwpPdfException>());
expect('$err2', contains('1~3'));
expect(slow.hold!.isCompleted, isTrue, reason: '시간 제한이 변환을 멈춤 (기다림만 끊지 않음)');
await tester.runAsync(s2!.dispose);
// 열기 실패 (암호 등) 는 그대로 알린다 - 파일 탐색기는 다른 앱으로 연다
Object? err3;
await tester.runAsync(() => HwpSource.open(doc.path, engine: engine, tempDir: dir.path, password: 'bad').then((_) {}, onError: (Object e) {
err3 = e;
}));
expect('$err3', contains('암호가 맞지 않습니다'));
});
}

View File

@ -11,6 +11,7 @@ import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:jj_mkvmaker/ui/monitor_page.dart';
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
import 'package:jj_mkvmaker/ui/theme.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
@ -227,7 +228,8 @@ void main() {
await tap(find.text('srcA').first);
await tap(find.text('dstB').last);
await tap(find.text('좌 → 우'));
await tap(find.text('원본 파일 지우기 (--remove-source-files)'));
// 옵션 이름은 줄바꿈되지 않게 묶여 그려진다 (rsync_narrow dfed3fa) - 화면에 그려지는 글 그대로 찾는다
await tap(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')));
await tap(find.text('빈 폴더 지움 · 원본 폴더도 지움'));
await shot('rsync_move_confirm');
await tap(find.widgetWithText(FilledButton, '이동'));

View File

@ -14,6 +14,10 @@ void main() {
final steps = File(p.join(Directory.systemTemp.path, 'jj_shell_steps.txt'));
if (steps.existsSync()) steps.deleteSync();
void mark(String s) => steps.writeAsStringSync('$s\n', mode: FileMode.append, flush: true);
// 관리자 지시 (10/20): 끝까지 돌면 단계 기록을 지운다 (앱이 도중에 꺼졌을 때만 남아 어디까지 갔는지 보인다)
tearDownAll(() {
if (steps.existsSync()) steps.deleteSync();
});
testWidgets('DesktopShell', (tester) async {
mark('start');

View File

@ -1,6 +1,7 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
/// 관리자 지시 (10/19, 28 이 찾음): 통합 시험 · 캡처가 %TEMP%\jj_* 시험 폴더를 남겼다 - 시험 끝 (실패해도) 에 지운다.
/// [base] 가 있으면 그 아래 (짧은 경로가 필요한 캡처). 반드시 시험 (testWidgets · test) 안에서 부른다 (addTearDown).
@ -14,6 +15,15 @@ Directory testTempDir(String prefix, {String? base, bool heldByApp = false}) {
return dir;
}
/// 관리자 지시 (10/20): 앱 임시 폴더 ([DesktopStorageService.tempDirectory] = %TEMP%\jj_mkvmaker) 를 이 시험 폴더 아래로 돌린다
/// (ai_e2e 가 남겨 test_temp_check 가 exit 2). 시험 끝에 기준을 되돌리고, 폴더는 [testTempDir] 의 정리가 함께 지운다
/// (addTearDown 은 나중에 넣은 것부터 돌아 앱 기준을 먼저 되돌린다). 사용자 %TEMP%\jj_mkvmaker 는 건드리지 않는다
void appTempInto(Directory dir) {
final before = DesktopStorageService.tempRoot;
DesktopStorageService.tempRoot = dir.path;
addTearDown(() => DesktopStorageService.tempRoot = before);
}
/// 관리자 지시 (10/20): tool/test_temp_check.ps1 이 TEMP · TMP 를 실행 폴더 (%TEMP%\t_jjrun_<표>) 로 돌리고 표를 JJ_TEST_TMP_TAG 로 넘긴다.
/// 표가 있는데 임시 폴더가 그 밖에 생기면 (환경 변수가 시험 · 앱까지 오지 않음) 다른 세션 시험과 섞이므로 시험을 실패시킨다
void expectInTaggedTemp(Directory dir) {

View File

@ -21,6 +21,7 @@ void main() {
testWidgets('화면 · 색 보정: 미리보기와 세로 9:16 MKV', (tester) async {
final dir = testTempDir('jj_adj_e2e_');
appTempInto(dir); // 앱 임시 폴더 (jj_mkvmaker) 도 이 아래로 - 끝에 함께 지움 (관리자 지시 10/20)
final video = p.join(dir.path, '가로 영상.mp4');
final ffmpeg = p.join(Directory.current.path, 'third_party', 'ffmpeg', 'windows', 'ffmpeg.exe');
final r = await Process.run(ffmpeg, [

View File

@ -1,7 +1,9 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton, kPrimaryButton, PointerDeviceKind;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@ -24,6 +26,7 @@ void main() {
late Directory tmp;
late String left, right;
late AppController c;
final shotKey = GlobalKey();
setUp(() async {
await i18n.apply('ko', save: false);
@ -70,7 +73,11 @@ void main() {
t.view.physicalSize = const Size(1600, 900);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
await t.pumpWidget(MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: ExplorerPage(c: c)));
await t.pumpWidget(MaterialApp(
theme: buildTheme(),
debugShowCheckedModeBanner: false,
builder: (_, child) => RepaintBoundary(key: shotKey, child: child),
home: ExplorerPage(c: c)));
await until(t, () => find.text('doc.txt').evaluate().isNotEmpty && find.text('r1').evaluate().isNotEmpty);
expect(find.text('doc.txt'), findsOneWidget);
}
@ -104,6 +111,61 @@ void main() {
int feedback() => find.byIcon(Icons.drive_file_move_outline).evaluate().length;
/// 관리자 지시 (10/20): 실패 때의 화면 캡처 (JJ_SHOT_DIR) 와 그때 열린 창 · 단추 · 글 목록
Future<void> dump(WidgetTester t, String what) async {
final texts = [for (final e in find.byType(Text).evaluate()) (e.widget as Text).data ?? (e.widget as Text).textSpan?.toPlainText() ?? ''];
final buttons = [
for (final e in find.byWidgetPredicate((w) => w is ButtonStyleButton || w is SnackBarAction).evaluate())
'${e.widget.runtimeType}: ${[for (final x in find.descendant(of: find.byWidget(e.widget), matching: find.byType(Text)).evaluate()) (x.widget as Text).data].join(' ')}'
];
// ignore: avoid_print
print('== $what 실패 때: 창 ${find.byType(Dialog).evaluate().length} · 스낵바 ${find.byType(SnackBar).evaluate().length}\n단추: $buttons\n글: $texts');
final dir = Platform.environment['JJ_SHOT_DIR'];
final b = shotKey.currentContext?.findRenderObject();
if (dir == null || dir.isEmpty || b is! RenderRepaintBoundary) return;
final img = await t.runAsync(() => b.toImage());
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
File(p.join(dir, 'b6_input_fail_${what.hashCode.toRadixString(16)}.png'))
..createSync(recursive: true)
..writeAsBytesSync(bytes!.buffer.asUint8List());
}
/// 관리자 지시 (10/20): [f] 가 보이는 것만이 아니라 정말 눌리는 자리에 올 때까지 기다린 뒤 누른다.
/// 스낵바는 아래에서 올라오는 동안 (약 0.25초) 글은 이미 있지만 그 자리를 누르면 아무것도 맞지 않는다 - 예전에는 그때 눌러 [되돌리기] 가 빗나갔다.
/// 끝내 눌리지 않으면 캡처 · 목록을 남기고 실패
Future<void> tapReady(WidgetTester t, Finder f, String what, {int ms = 8000}) async {
final sw = Stopwatch()..start();
int? seen;
bool within(Object? target, RenderObject box) {
for (RenderObject? x = target is RenderObject ? target : null; x != null; x = x.parent) {
if (identical(x, box)) return true;
}
return false;
}
for (var i = 0; i < ms ~/ 50; i++) {
final e = f.evaluate();
if (e.length == 1) {
seen ??= sw.elapsedMilliseconds;
final box = e.single.renderObject;
if (box is RenderBox && box.attached && box.hasSize) {
final at = box.localToGlobal(box.size.center(Offset.zero));
final hit = HitTestResult();
t.binding.hitTestInView(hit, at, t.view.viewId);
if (hit.path.any((x) => within(x.target, box))) {
// ignore: avoid_print
print('누를 수 있음: $what - 보인 뒤 ${sw.elapsedMilliseconds - seen}ms');
await t.tapAt(at);
return;
}
}
}
await t.pump(const Duration(milliseconds: 50));
}
await dump(t, what);
fail('$what 을 누를 수 없음 (보인 때: ${seen == null ? '안 보임' : '${seen}ms'})');
}
testWidgets('82: 8px 문턱 · 놓을 곳 강조 · 같은 창은 빨간 테두리 · 묻기 창 [이동] · [되돌리기]', (t) async {
await open(t);
final doc = t.getCenter(find.text('doc.txt'));
@ -141,17 +203,15 @@ void main() {
await until(t, () => find.text('1개 항목을 놓았습니다').evaluate().isNotEmpty);
expect(find.text('1개 항목을 놓았습니다'), findsOneWidget);
expect(find.widgetWithText(FilledButton, '복사'), findsOneWidget, reason: '처음 고른 단추는 [복사]');
await t.tap(find.widgetWithText(OutlinedButton, '이동'));
await tapReady(t, find.widgetWithText(OutlinedButton, '이동'), '묻기 창 [이동]');
// 지금의 이동 확인 창
await until(t, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty);
await t.tap(find.widgetWithText(FilledButton, '이동'));
await tapReady(t, find.widgetWithText(FilledButton, '이동'), '이동 확인 창 [이동]');
await until(t, () => find.text('되돌리기').evaluate().isNotEmpty);
expect(File(p.join(right, 'doc.txt')).existsSync(), isTrue);
expect(File(p.join(left, 'doc.txt')).existsSync(), isFalse);
// 4) [되돌리기] → 원래 자리로 (지금의 이동 확인 창)
await t.tap(find.text('되돌리기'));
await until(t, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty);
await t.tap(find.widgetWithText(FilledButton, '이동'));
await tapReady(t, find.text('되돌리기'), '[되돌리기]');
await tapReady(t, find.widgetWithText(FilledButton, '이동'), '되돌리기의 이동 확인 창 [이동]');
await until(t, () => File(p.join(left, 'doc.txt')).existsSync() && !File(p.join(right, 'doc.txt')).existsSync());
expect(File(p.join(left, 'doc.txt')).readAsStringSync(), 'hello');
expect(File(p.join(right, 'doc.txt')).existsSync(), isFalse);

View File

@ -33,6 +33,7 @@ void main() {
await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false));
final tmp = testTempDir('jj_xpshot_');
appTempInto(tmp); // 앱 임시 폴더 (jj_mkvmaker) 도 이 아래로 - 끝에 함께 지움 (관리자 지시 10/20)
final a = Directory(p.join(tmp.path, 'Videos'))..createSync();
final b = Directory(p.join(tmp.path, 'Backup'))..createSync();
for (final n in ['Show S01', 'Movies']) {
@ -74,6 +75,13 @@ void main() {
}
await settle(20);
// 관리자 지시 (10/20): 목록은 따로 읽혀 늦게 올 수 있다 (회귀에서 en 412 가 1.2초 안에 못 받아 실패) - 정해진 시간이 아니라 그 줄이 올 때까지 (최대 약 10초)
final load = Stopwatch()..start();
for (var i = 0; i < 160 && find.text('Show S01').evaluate().isEmpty; i++) {
await settle(1);
}
// ignore: avoid_print
print('목록 받음: $lang $w - 1.2초 뒤 ${load.elapsedMilliseconds}ms 더');
// Show S01 펼치기 · 즐겨찾기 펼치기
await t.tap(find.text('Show S01').first);
await settle();

View File

@ -32,7 +32,6 @@ import '../services/platform_services.dart';
import '../services/system_usage.dart';
import 'settings.dart';
import 'cleanup.dart' show removeUpdateLeftovers;
import '../core/hwp_source.dart' show removeStaleHwpWork;
import '../l10n/tr.dart';
import '../core/remote_idle.dart';
@ -214,14 +213,6 @@ class AppController extends ChangeNotifier {
final gone = removeUpdateLeftovers(Directory.systemTemp.path);
if (gone.isNotEmpty) _log(trf('지난 업데이트가 남긴 임시 파일을 지움: {0}', [gone.join(', ')]));
}
// 관리자 지시 (10/20): 강제로 끝나 남은 HWP 보기 작업 폴더 (바꾼 PDF 들). 앱 시작을 기다리게 하지 않는다 (28 지적: 파일 IO 를
// 기다리면 시험의 가짜 시간 안에서 끝나지 않았다 - 시작을 늦출 이유도 없음)
unawaited(() async {
try {
final hwp = removeStaleHwpWork(await services.storage.tempDirectory());
if (hwp.isNotEmpty) _log(trf('지난번에 남은 문서 보기 임시 폴더를 지움: {0}개', [hwp.length]));
} catch (_) {}
}());
ffmpegVersion = await _tool.version();
if (ffmpegVersion == null) {
_log(tr('⚠ FFmpeg 를 찾을 수 없습니다. MKV 만들기를 사용할 수 없습니다.'));

View File

@ -648,8 +648,11 @@ class LiveSync extends ChangeNotifier {
/// 41 · 42 의 확인을 건너뛰지 않게 뺀다 (97)
static List<String> rsyncArgsFor(LiveSyncPair x, AppSettings s, {required bool windows}) => [
// 107: 원본을 지우는 옵션 (--remove-source-files) 도 실시간 동기화에서는 쓰지 않는다
...splitOptions(s.rsyncOptions)
.where((o) => o != '-P' && o != '--progress' && !isRsyncDeleteOption(o) && !isSourceRemovingOption(o)),
// 관리자 판단 (10/20, 28 확인): 목록 · 기록 · 배치 파일의 C:\… 는 rsync 가 읽는 꼴로 (rsyncFileOptionArgs)
...rsyncFileOptionArgs([
for (final o in splitOptions(s.rsyncOptions))
if (o != '-P' && o != '--progress' && !isRsyncDeleteOption(o) && !isSourceRemovingOption(o)) o,
], windows: windows),
if (!windows) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음)
if (s.copyBandwidthKBps > 0) '--bwlimit=${s.copyBandwidthKBps}',
if (x.delete) '--delete',
@ -658,7 +661,7 @@ class LiveSync extends ChangeNotifier {
if (x.delete && splitOptions(s.rsyncOptions).contains('--delete-excluded')) '--delete-excluded',
...appTempRsyncExcludes, // 78 보충: 이름 바꾸는 중의 임시 이름은 보내지도 지우지도 않는다
'${toCygwinPath(x.source, windows: windows)}/',
'${toCygwinPath(x.target, windows: windows)}/',
'${rsyncDestPath(x.target, windows: windows)}/', // 받는 쪽 UNC 는 \\서버\… (28 · 15 확인 10/20)
];
/// robocopy 인수 (실시간 동기화). 지우기 (/MIR · /PURGE) 는 쌍의 "지우기 포함" 으로만 (/MIR 는 /E 로) - 97

View File

@ -1,363 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:path/path.dart' as p;
import 'package:pdfrx/pdfrx.dart';
import 'reader_sources.dart';
import 'vfs.dart';
import '../l10n/tr.dart';
/// HWP · HWPX 확장자 (앱 안에서 PDF 로 바꿔 보기 - Android, rhwp)
const hwpExtensions = ['hwp', 'hwpx'];
/// 관리자 지시 (10/20): 앱이 강제로 끝나 남은 HWP 작업 폴더 (앱 임시 폴더의 jj_hwp_…, 바꾼 PDF 들) 를 앱이 켜질 때 지운다.
/// 막 생긴 것 ([minAge] 안) 은 남긴다. 지운 이름을 돌려준다
List<String> removeStaleHwpWork(String tempDir, {Duration minAge = const Duration(minutes: 1), DateTime? now}) {
final before = (now ?? DateTime.now()).subtract(minAge);
final removed = <String>[];
try {
for (final e in Directory(tempDir).listSync(followLinks: false)) {
final name = p.basename(e.path);
if (e is! Directory || !name.startsWith('jj_hwp_')) continue;
try {
if (!e.statSync().modified.isBefore(before)) continue;
e.deleteSync(recursive: true);
removed.add(name);
} catch (_) {}
}
} catch (_) {}
return removed;
}
/// HWP · HWPX → PDF 엔진 (Android: APK 의 libjj_rhwp.so - lib/platform/android/hwp_pdf_ffi.dart, 시험: 가짜)
abstract class HwpPdfEngine {
/// 문서 열기 (암호가 있으면 [password]). 실패하면 [HwpPdfException]
Future<HwpPdfDoc> open(String path, {String? password});
}
/// 연 문서: 쪽 수와 쪽 범위 → PDF
abstract class HwpPdfDoc {
int get pageCount;
/// [from] 부터 [to] 앞까지 (0 부터) 를 PDF 파일 [out] 으로. [fontDirs] 는 글꼴을 찾을 폴더들.
/// [timeout]: 바꾸기가 실제로 시작된 때부터 재고, 넘으면 그 요청만 멈추고 [HwpErrorKind.timeout]
Future<void> exportPdf(int from, int to, String out, {List<String> fontDirs = const [], Duration? timeout});
/// 닫는다: 도는 바꾸기는 멈추고, 줄에 선 것은 시작하지 않는다 ([HwpErrorKind.closed])
Future<void> close();
}
/// 변환기의 C 함수 (Android: libjj_rhwp.so - lib/platform/android/hwp_pdf_ffi.dart, 시험: 가짜).
/// 요청 번호 [export] 의 req 는 [cancel] 로 그 요청만 멈출 때 쓴다
abstract class HwpNative {
int get pageCount;
/// (오류 코드 - 0 이면 성공, 원문)
Future<(int, String?)> export(int req, int from, int to, String fontDirs, String out);
/// 요청 [req] 만 멈춘다 (도는 중이면 다음 쪽에서, 시작 전이면 시작하자마자)
void cancel(int req);
/// 닫기 전에 모두 멈춘다
void cancelAll();
Future<void> close();
}
/// 관리자 지시 (10/20, 28 지적): 한 문서의 바꾸기를 한 번에 하나씩 줄 세우고, 시간 제한 · 멈춤을 요청마다 한다.
/// - 시간 제한은 바꾸기가 실제로 시작된 때부터 잰다 (예전에는 줄에 선 때부터 - 앞 묶음이 길면 뒤 묶음이 시작 전에 넘었다).
/// - 넘으면 그 요청만 멈춘다 (예전에는 문서 하나의 깃발이라 도는 앞 묶음이 멈췄다). 멈춘 바꾸기가 끝날 때까지 다음 것은
/// 시작하지 않는다 (한 문서를 두 스레드가 함께 쓰지 않게).
/// - 닫으면 도는 것은 멈추고 줄에 선 것은 시작하지 않는다 (예전에는 시작할 때 깃발을 지워 닫기 요청이 사라졌다).
class QueuedHwpDoc implements HwpPdfDoc {
final HwpNative native;
QueuedHwpDoc(this.native);
@override
int get pageCount => native.pageCount;
var _seq = 0;
var _closed = false;
Future<void> _queue = Future.value();
Future<void>? _closing;
@override
Future<void> exportPdf(int from, int to, String out, {List<String> fontDirs = const [], Duration? timeout}) {
if (_closed) return Future.error(const HwpPdfException(HwpErrorKind.closed));
final req = ++_seq;
final result = Completer<void>();
_queue = _queue.then((_) async {
if (_closed) {
result.completeError(const HwpPdfException(HwpErrorKind.closed));
return;
}
Timer? timer;
if (timeout != null) {
timer = Timer(timeout, () {
if (result.isCompleted) return;
native.cancel(req);
result.completeError(HwpPdfException(
HwpErrorKind.timeout, 'timeout ${timeout.inSeconds}s (pages ${from + 1}~$to)', [from + 1, to, timeout.inSeconds]));
});
}
try {
final (code, err) = await native.export(req, from, to, fontDirs.join('\n'), out);
if (!result.isCompleted) {
if (code == 0) {
result.complete();
} else {
result.completeError(HwpPdfException(_closed ? HwpErrorKind.closed : hwpErrorKindOf(code), err ?? ''));
}
}
} catch (e, st) {
if (!result.isCompleted) result.completeError(e, st);
} finally {
timer?.cancel();
}
});
return result.future;
}
@override
Future<void> close() => _closing ??= () async {
_closed = true;
native.cancelAll();
await _queue; // 도는 것이 멈춰 끝날 때까지 (줄에 선 것은 시작하지 않고 넘어감)
await native.close();
}();
}
/// 관리자 지시 (10/20, 28 지적): 변환기 오류의 종류 - 화면에는 종류에 맞는 번역 글, 원문은 [HwpPdfException.detail] ([자세히] · 작업 기록).
/// 앞 9개는 tool/rhwp_ffi 의 JJ_RHWP_E_* 와 같은 순서 (1 부터)
enum HwpErrorKind { cancelled, read, invalid, passwordRequired, passwordWrong, range, render, write, internal, timeout, closed }
/// tool/rhwp_ffi 의 오류 코드 (JJ_RHWP_E_*) → 종류 (모르는 코드는 내부 오류)
HwpErrorKind hwpErrorKindOf(int code) => code >= 1 && code <= 9 ? HwpErrorKind.values[code - 1] : HwpErrorKind.internal;
class HwpPdfException implements Exception {
final HwpErrorKind kind;
/// 변환기 원문 (화면에는 보이지 않고 [자세히] · 작업 기록에)
final String detail;
/// 글에 넣을 값 (시간 제한: 첫 쪽 · 끝 쪽 · 초)
final List<Object> args;
const HwpPdfException(this.kind, [this.detail = '', this.args = const []]);
/// 화면에 보일 글 (화면 언어)
String get message => switch (kind) {
HwpErrorKind.cancelled => tr('그만두었습니다'),
HwpErrorKind.read => tr('파일을 읽을 수 없습니다'),
HwpErrorKind.invalid => tr('HWP · HWPX 문서로 읽을 수 없습니다 (깨졌거나 지원하지 않는 형식)'),
HwpErrorKind.passwordRequired => tr('암호가 걸린 문서입니다'),
HwpErrorKind.passwordWrong => tr('암호가 맞지 않습니다'),
HwpErrorKind.range => tr('쪽 범위가 맞지 않습니다'),
HwpErrorKind.render => tr('이 쪽을 그리지 못했습니다'),
HwpErrorKind.write => tr('바꾼 PDF 를 저장하지 못했습니다 (저장 공간을 확인해 주세요)'),
HwpErrorKind.internal => tr('변환기 안에서 오류가 났습니다'),
HwpErrorKind.timeout => trf('{0}~{1}쪽을 {2}초 안에 바꾸지 못했습니다', args),
HwpErrorKind.closed => tr('닫힌 문서입니다'),
};
@override
String toString() => message;
}
/// 바꾼 PDF 한 묶음 (몇 쪽). 기본은 pdfrx ([pdfChunkOpener]) - 시험은 PDFium 없이 가짜로
abstract class HwpChunk {
int get pageCount;
Future<ReaderImage> render(int index, {required int maxWidth});
Future<void> dispose();
}
/// PDF 파일을 묶음으로 연다
typedef HwpChunkOpener = Future<HwpChunk> Function(String pdfPath);
/// 기본: pdfrx 로 연다
Future<HwpChunk> pdfChunkOpener(String pdfPath) async {
await pdfrxFlutterInitialize();
return _PdfChunk(await PdfDocument.openFile(pdfPath));
}
class _PdfChunk implements HwpChunk {
final PdfDocument doc;
_PdfChunk(this.doc);
@override
int get pageCount => doc.pages.length;
@override
Future<ReaderImage> render(int index, {required int maxWidth}) async {
final page = doc.pages[index];
final w = maxWidth.toDouble();
final h = w * page.height / page.width;
final image = await page.render(fullWidth: w, fullHeight: h, backgroundColor: 0xffffffff);
if (image == null) return const ReaderImage();
try {
return ReaderImage(image: await image.createImage());
} finally {
image.dispose();
}
}
@override
Future<void> dispose() => doc.dispose();
}
/// 관리자 지시 (10/20): HWP · HWPX 를 보기 화면으로 - 큰 문서 (편람 383쪽은 PC 에서도 14분) 를 통째로 바꾸지 않고,
/// 보는 쪽이 든 [chunk] 쪽 묶음만 PDF 로 바꿔 보이면서 다음 묶음을 뒤에서 미리 만든다. 묶음마다 [timeout],
/// 닫으면 (dispose) 더 만들지 않는다. 실패한 묶음은 그 쪽에 이유가 보이고 [다시 시도] 로 다시 만든다 (보기 화면).
/// 2단계 (10/20): 메모리 - 열어 둔 PDF 묶음은 최근 것 [maxOpenChunks] 개까지 (몇백 쪽 문서도 늘지 않게). 닫은 묶음의 파일은
/// 남겨 다시 볼 때 바꾸지 않고 파일만 연다. 그리고 있는 묶음은 닫지 않는다.
class HwpSource extends ReaderSource {
final String path;
final HwpPdfDoc doc;
final Directory work;
final List<String> fontDirs;
final int chunk;
final Duration timeout;
final int maxOpenChunks;
final HwpChunkOpener opener;
@override
final String title;
/// 열어 둔 묶음 (넣은 순서 = 최근에 쓴 순서, 앞이 오래된 것)
final _open = <int, Future<HwpChunk>>{};
/// PDF 파일을 다 만든 묶음
final _made = <int>{};
/// 지금 그리고 있는 묶음 (닫지 않는다)
final _busy = <int, int>{};
var _disposed = false;
HwpSource._(this.path, this.doc, this.work, this.title,
{required this.fontDirs, required this.chunk, required this.timeout, required this.maxOpenChunks, required this.opener, this.log});
/// 실패의 원문을 남길 곳 (작업 기록 - 파일 탐색기가 AppController.note 를 준다)
final void Function(String line)? log;
/// 문서를 연다 (WebDAV 등은 [tempDir] 로 받은 뒤). 열지 못하면 [HwpPdfException]
static Future<HwpSource> open(String path,
{required HwpPdfEngine engine,
required String tempDir,
String? password,
List<String> fontDirs = const [],
int chunk = 5,
Duration timeout = const Duration(minutes: 3),
int maxOpenChunks = 3,
HwpChunkOpener opener = pdfChunkOpener,
void Function(String line)? log}) async {
final local = await vLocalCopy(path, tempDir);
final HwpPdfDoc doc;
try {
doc = await engine.open(local, password: password);
} on HwpPdfException catch (e) {
log?.call('HWP ${vBasename(path)}: ${e.kind.name} ${e.detail}');
rethrow;
}
final work = Directory(p.join(tempDir, 'jj_hwp_${DateTime.now().microsecondsSinceEpoch}'))..createSync(recursive: true);
return HwpSource._(path, doc, work, vBasename(path),
fontDirs: fontDirs, chunk: chunk, timeout: timeout, maxOpenChunks: math.max(2, maxOpenChunks), opener: opener, log: log);
}
@override
int get length => doc.pageCount;
@override
String pageName(int i) => '${i + 1} / $length';
/// 열어 둔 묶음 번호 (오래된 것부터 - 시험용)
List<int> get openChunks => _open.keys.toList();
/// PDF 파일을 만든 묶음 번호 (시험용)
Set<int> get madeChunks => Set.unmodifiable(_made);
/// [k] 번째 묶음 (실패하면 지워서 다음에 다시)
Future<HwpChunk> _chunk(int k) {
final had = _open.remove(k);
if (had != null) {
_open[k] = had; // 최근에 쓴 것으로
return had;
}
late final Future<HwpChunk> f;
f = _openChunk(k).onError<Object>((e, st) {
if (identical(_open[k], f)) _open.remove(k);
Error.throwWithStackTrace(e, st);
});
_open[k] = f;
_evict();
return f;
}
Future<HwpChunk> _openChunk(int k) async {
if (_disposed) throw StateError(tr('닫힌 문서입니다'));
final out = p.join(work.path, 'p$k.pdf');
if (!_made.contains(k) || !File(out).existsSync()) {
final from = k * chunk, to = math.min(from + chunk, length);
try {
// 시간 제한은 문서 ([QueuedHwpDoc]) 가 바꾸기가 시작된 때부터 재고, 넘으면 그 요청만 멈춘다
await doc.exportPdf(from, to, out, fontDirs: fontDirs, timeout: timeout);
} catch (e) {
// 닫은 뒤에 끝난 변환은 (임시 폴더를 이미 지워 · 멈춰) 실패한다 - 그 이유 대신 "닫힌 문서"
if (_disposed) throw StateError(tr('닫힌 문서입니다'));
if (e is HwpPdfException) log?.call('HWP ${vBasename(path)} ${from + 1}~$to: ${e.kind.name} ${e.detail}');
rethrow;
}
_made.add(k);
}
if (_disposed) throw StateError(tr('닫힌 문서입니다'));
final c = await opener(out);
if (_disposed) {
await c.dispose();
throw StateError(tr('닫힌 문서입니다'));
}
return c;
}
/// 열어 둔 묶음이 [maxOpenChunks] 를 넘으면 오래된 것부터 닫는다 (그리고 있는 것은 빼고)
void _evict() {
while (_open.length > maxOpenChunks) {
final victim = _open.keys.where((k) => (_busy[k] ?? 0) == 0).firstOrNull;
if (victim == null) return;
final f = _open.remove(victim)!;
unawaited(f.then((c) => c.dispose(), onError: (Object _) {}));
}
}
@override
Future<ReaderImage> load(int i, {required int maxWidth}) async {
final k = i ~/ chunk;
_busy[k] = (_busy[k] ?? 0) + 1;
try {
final c = await _chunk(k);
// 다음 묶음을 미리 (넘겨 볼 때 기다리지 않게) - 실패해도 여기서는 알리지 않는다 (그 쪽을 볼 때 보임)
if ((k + 1) * chunk < length) unawaited(_chunk(k + 1).then((_) {}, onError: (Object _) {}));
return await c.render(i - k * chunk, maxWidth: maxWidth);
} finally {
final n = (_busy[k] ?? 1) - 1;
if (n <= 0) {
_busy.remove(k);
} else {
_busy[k] = n;
}
if (!_disposed) _evict();
}
}
@override
Future<void> dispose() async {
_disposed = true;
final open = [..._open.values];
_open.clear();
// 다 연 묶음은 닫기를 기다리고 (열린 PDF 를 닫아야 임시 폴더를 지울 수 있다 - Windows 는 연 파일을 잡는다),
// 만드는 중인 묶음은 기다리지 않는다 (몇 분 걸릴 수 있음 - 끝나면 스스로 닫힌다, [_openChunk] 가 _disposed 를 본다)
for (final f in open) {
final closing = f.then((c) => c.dispose(), onError: (Object _) {});
await Future.any([closing, Future<void>.delayed(const Duration(milliseconds: 200))]);
}
await doc.close(); // 도는 바꾸기는 다음 쪽에서 멈추고, 줄에 선 것은 시작하지 않는다
try {
if (work.existsSync()) work.deleteSync(recursive: true);
} catch (_) {}
}
}

View File

@ -203,7 +203,7 @@ const _rsyncLongWithValue = {
List<String> unreadableOptions(String options, {bool? windows}) => [for (final x in optionProblems(options, windows: windows)) x.text];
/// 옵션 칸의 문제 종류 - 안내의 끝 문구 (고칠 방법) 를 고른다 (관리자 지시 10/20, 28 지적)
enum OptionProblemKind { unclosed, path, noValue, odd, doubleQuote, relativePath }
enum OptionProblemKind { unclosed, path, noValue, odd, doubleQuote, relativePath, stdin }
/// [unreadableOptions] 의 한 줄과 그 종류
class OptionProblem {
@ -219,7 +219,8 @@ class OptionProblem {
}
/// 문제 종류에 맞는 고칠 방법 (여러 종류면 · 로 잇는다)
String optionProblemHint(Iterable<OptionProblem> problems) {
String optionProblemHint(Iterable<OptionProblem> problems, {bool? windows}) {
windows ??= Platform.isWindows;
final kinds = {for (final x in problems) x.kind};
return [
if (kinds.contains(OptionProblemKind.unclosed)) tr('따옴표를 닫아 주세요'),
@ -227,7 +228,9 @@ String optionProblemHint(Iterable<OptionProblem> problems) {
if (kinds.contains(OptionProblemKind.noValue)) tr('값을 적어 주세요 (예: --exclude=*.tmp)'),
if (kinds.contains(OptionProblemKind.odd)) tr('곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'),
if (kinds.contains(OptionProblemKind.doubleQuote)) tr('큰따옴표 (") 를 빼 주세요'),
if (kinds.contains(OptionProblemKind.relativePath)) tr('드라이브 (C:) 나 / 로 시작하는 경로로'),
// 관리자 지시 (10/20): Windows 는 드라이브로 시작하는 전체 경로만 ("/" 는 rsync 가 자기 설치 폴더 기준으로 읽음), Android 는 /
if (kinds.contains(OptionProblemKind.relativePath)) windows ? tr('C:\\ 처럼 드라이브로 시작하는 전체 경로로') : tr('/ 로 시작하는 전체 경로로'),
if (kinds.contains(OptionProblemKind.stdin)) tr('목록 파일의 전체 경로를 적어 주세요'),
].join(' · ');
}
@ -256,8 +259,8 @@ List<OptionProblem> optionProblems(String options, {bool? windows}) {
continue;
}
if (t.startsWith('---')) {
// 관리자 판단 (10/20, 28 확인): 하이픈이 셋 이상 (---delete) 은 rsync 가 알아보지 못한다
bad.add(OptionProblem(OptionProblemKind.odd, t, t));
// 관리자 판단 (10/20, 28 확인): 하이픈이 셋 이상 (---delete) 은 rsync 가 알아보지 못한다 - 끝 문구는 "옵션은 - 나 -- 로 시작합니다"
bad.add(OptionProblem(OptionProblemKind.path, t, t));
} else if (t.startsWith('--')) {
value = !t.contains('=') && _rsyncLongWithValue.contains(t);
last = t;
@ -278,12 +281,17 @@ List<OptionProblem> optionProblems(String options, {bool? windows}) {
// 관리자 판단 (10/20): 목록 파일의 상대 경로 - rsync 는 자기 작업 폴더 (Windows 는 응답 파일 폴더) 기준으로, 미리 보기는 앱의
// 작업 폴더 기준으로 읽어 서로 다른 파일을 볼 수 있다. 전체 경로만 받는다
for (final f in listFileValues(parsed.words)) {
if (!_absolutePath(f)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('목록 파일은 전체 경로로 적어 주세요: {0}', [f]), f));
// 관리자 지시 (10/20, 28 지적): "-" (표준 입력) 는 따로 - 앱은 rsync 에 표준 입력을 주지 않는다
if (f == '-') {
bad.add(OptionProblem(OptionProblemKind.stdin, tr('표준 입력 (-) 은 쓸 수 없습니다'), f));
} else if (!_absolutePath(f, windows: windows)) {
bad.add(OptionProblem(OptionProblemKind.relativePath, trf('목록 파일은 전체 경로로 적어 주세요: {0}', [f]), f));
}
}
// 관리자 지시 (10/20, 28 확인): 기록 · 배치 파일도 - 상대 경로면 응답 파일 임시 폴더에 생겼다가 함께 지워져 찾을 수 없다.
// --backup-dir · --partial-dir · --temp-dir · --compare-dest 류는 받는 쪽 기준이라 그대로
for (final f in _optionValues(parsed.words, const {'--log-file', '--write-batch', '--read-batch', '--only-write-batch'})) {
if (!_absolutePath(f)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('기록 · 배치 파일은 전체 경로로 적어 주세요: {0}', [f]), f));
if (!_absolutePath(f, windows: windows)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('기록 · 배치 파일은 전체 경로로 적어 주세요: {0}', [f]), f));
}
return bad;
}
@ -332,11 +340,84 @@ List<String> listFileValues(List<String> words) {
return out;
}
/// 전체 경로 (C:\ · C:/ · \\서버 · / 로 시작 - /cygdrive/c/… 와 Android 경로 포함)
bool _absolutePath(String f) => RegExp(r'^(?:[A-Za-z]:[\\/]|[\\/])').hasMatch(f);
/// 전체 경로. Windows: 드라이브 (C:\ · C:/) · UNC (\\서버 · //서버) · /cygdrive/… - 그 밖의 / 는 MSYS 가 자기 설치 폴더 기준으로 읽어 뺀다.
/// Android: / 로 시작
bool _absolutePath(String f, {required bool windows}) =>
windows ? RegExp(r'^(?:[A-Za-z]:[\\/]|\\\\|//|/cygdrive/)').hasMatch(f) : f.startsWith('/');
/// 관리자 판단 (10/20, 28 확인): rsync (Windows MSYS2) 는 목록 · 기록 · 배치 파일의 C:\… · C:/… · \\서버\… 를 열지 못한다
/// ("failed to open exclude file", exit 11) - 그 값들을 경로처럼 [toCygwinPath] 로 바꿔 넘긴다 (막지 않음).
/// 대상: --exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일" · --log-file · --write-batch · --read-batch · --only-write-batch
List<String> rsyncFileOptionArgs(List<String> words, {required bool windows}) {
if (!windows) return words;
const opts = {
'--exclude-from', '--include-from', '--files-from', '--log-file', '--write-batch', '--read-batch', '--only-write-batch',
};
// 28 · 15 확인 (10/20, MSYS 3.5.1): 이 파일들은 UNC 를 \\서버\… 로만 연다 (//서버/… 는 "failed to open", exit 11) - UNC 는 \\ 로,
// 드라이브는 /cygdrive/… 로
String conv(String v) => RegExp(r'^[A-Za-z]:[\\/]').hasMatch(v)
? toCygwinPath(v)
: RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(v)
? v.replaceAll('/', '\\')
: v;
String rule(String r) {
final m = RegExp(r'^(\s*(?:\.|merge)(?:,[^\s]*)?\s+)(.+)$').firstMatch(r);
return m == null ? r : '${m[1]}${conv(m[2]!)}';
}
final out = <String>[];
for (var i = 0; i < words.length; i++) {
final w = words[i];
final next = i + 1 < words.length ? words[i + 1] : null;
final eq = w.indexOf('=');
if (w.startsWith('--') && eq > 0 && opts.contains(w.substring(0, eq))) {
out.add('${w.substring(0, eq + 1)}${conv(w.substring(eq + 1))}');
} else if (opts.contains(w) && next != null) {
out
..add(w)
..add(conv(next));
i++;
} else if (w.startsWith('--filter=')) {
out.add('--filter=${rule(w.substring(9))}');
} else if (w == '--filter' && next != null) {
out
..add(w)
..add(rule(next));
i++;
} else if (!w.startsWith('--') && w.length > 1 && w.startsWith('-')) {
// 짧은 옵션 묶음의 f: 붙은 값 (-f". 목록") 또는 다음 낱말 (-avf ". 목록")
final k = w.indexOf(RegExp('[BefMT@]'), 1);
if (k >= 0 && w[k] == 'f' && k < w.length - 1) {
out.add('${w.substring(0, k + 1)}${rule(w.substring(k + 1))}');
} else if (k >= 0 && w[k] == 'f' && next != null) {
out
..add(w)
..add(rule(next));
i++;
} else {
out.add(w);
// 다른 값 받는 글자 (-e ssh 등) 의 값은 그대로 건너뜀
if (k == w.length - 1 && next != null) {
out.add(next);
i++;
}
}
} else {
out.add(w);
}
}
return out;
}
/// Windows 경로 → MSYS2 rsync 가 읽는 경로 (C:\a\b → /cygdrive/c/a/b, \\server\share\x → //server/share/x).
/// MSYS2 rsync 는 "C:" 를 원격 컴퓨터 이름으로 읽으므로 꼭 바꿔야 한다.
/// 받는 쪽 (대상) 경로를 rsync 에 넘길 꼴로. 28 · 15 확인 (10/20, MSYS 3.5.1): 받는 쪽의 UNC 는 \\서버\… 만 된다
/// (//서버/… 는 change_dir 실패, exit 3) - 원본은 거꾸로 //서버/… 만 되어 [toCygwinPath] 그대로
String rsyncDestPath(String path, {bool windows = true}) {
if (windows && RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(path)) return path.replaceAll('/', '\\');
return toCygwinPath(path, windows: windows);
}
String toCygwinPath(String path, {bool windows = true}) {
if (!windows) return path;
final s = path.replaceAll('\\', '/');
@ -364,7 +445,7 @@ List<String> rsyncArgs({
bool contents = false,
}) {
// 178: 원본을 지우는 옵션은 [move] (이동 · [원본 파일 지우기]) 로만 - 옵션 글에 있어도 빼고, 이동일 때만 넣는다
final opts = [for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o];
final opts = rsyncFileOptionArgs([for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o], windows: windows);
return [
...opts,
if (bandwidthKBps > 0 && !opts.any((o) => o.startsWith('--bwlimit'))) '--bwlimit=$bandwidthKBps',
@ -374,7 +455,7 @@ List<String> rsyncArgs({
...appTempRsyncExcludes, // 78 보충: 일괄 이름 바꾸기 중의 임시 이름 (~jjren_ · ~jjback_) 은 보내지 않는다
// [contents]: 폴더 "안의 것" 을 대상에 (rsync 의 끝 / - 동기화 · lsync 와 같은 모양)
for (final s in sources) '${toCygwinPath(_noTrailingSlash(s), windows: windows)}${contents ? '/' : ''}',
'${toCygwinPath(_noTrailingSlash(dest), windows: windows)}/',
'${rsyncDestPath(_noTrailingSlash(dest), windows: windows)}/',
];
}

View File

@ -1,162 +0,0 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:isolate';
import 'package:ffi/ffi.dart';
import '../../core/hwp_source.dart';
/// 관리자 지시 (10/20): Android 의 HWP · HWPX → PDF - APK 에 든 libjj_rhwp.so (rhwp v0.8.7, MIT - tool/build_rhwp_android.sh ·
/// tool/rhwp_ffi) 를 dart:ffi 로 부른다. 여는 것 · 바꾸는 것은 다른 isolate 에서 (화면을 막지 않게). 줄 세우기 · 시간 제한 ·
/// 멈춤은 [QueuedHwpDoc] 가 한다 (요청마다). 라이브러리는 HWP 를 처음 열 때 읽는다 (앱을 켤 때 읽지 않음).
class RhwpEngine implements HwpPdfEngine {
static const libName = 'libjj_rhwp.so';
/// 글꼴을 찾을 폴더 (Android 는 rhwp 가 시스템 글꼴을 스스로 찾지 않는다 - 한글은 Noto Sans/Serif CJK KR 로 대신 그림).
/// 관리자 판단 (10/20): 한글 글꼴 컴포넌트를 받으면 그 폴더를 앞에 더한다 (아직 없음)
static const fontDirs = ['/system/fonts'];
/// 이 Dart 가 기대하는 C ABI 의 판 (tool/rhwp_ffi 의 JJ_RHWP_ABI_VERSION 과 같아야 함).
/// 3: 요청별 멈춤 (export 에 요청 번호 · jj_rhwp_cancel(번호) · jj_rhwp_cancel_all)
static const abiVersion = 3;
/// 꼭 있어야 하는 C 함수
static const requiredSymbols = [
'jj_rhwp_abi_version', 'jj_rhwp_open', 'jj_rhwp_page_count', 'jj_rhwp_export_pdf', 'jj_rhwp_cancel', 'jj_rhwp_cancel_all', //
'jj_rhwp_close', 'jj_rhwp_free',
];
static bool? _available;
/// 라이브러리를 읽을 수 있고 ABI 가 맞는지 (아니면 다른 앱으로 연다). 처음 HWP 를 열 때 한 번 본다.
/// 관리자 지시 (10/20, 28 지적): 옛 .so 에 새 인수를 넘기면 힙이 망가지므로, 기호와 판이 맞을 때만
static bool get available => _available ??= () {
try {
final lib = DynamicLibrary.open(libName);
return abiCompatible(
has: lib.providesSymbol, abi: () => lib.lookupFunction<Int32 Function(), int Function()>('jj_rhwp_abi_version')());
} catch (_) {
return false;
}
}();
/// [requiredSymbols] 가 모두 있고 [abi] 가 [abiVersion] 인지 (시험용으로 따로 - 기호를 찾다 · 부르다 실패하면 false)
static bool abiCompatible({required bool Function(String name) has, required int Function() abi}) {
try {
if (!requiredSymbols.every(has)) return false;
return abi() == abiVersion;
} catch (_) {
return false;
}
}
@override
Future<HwpPdfDoc> open(String path, {String? password}) async {
final (handle, code, err) = await Isolate.run(() => _open(path, password ?? ''));
if (handle == 0) throw HwpPdfException(hwpErrorKindOf(code), err ?? '');
final pages = await Isolate.run(() => _pageCount(handle));
return QueuedHwpDoc(_RhwpNative(handle, pages));
}
}
class _RhwpNative implements HwpNative {
final int _handle;
@override
final int pageCount;
var _closed = false;
_RhwpNative(this._handle, this.pageCount);
@override
Future<(int, String?)> export(int req, int from, int to, String fontDirs, String out) {
final h = _handle;
return Isolate.run(() => _export(h, req, from, to, fontDirs, out));
}
/// 줄을 서지 않고 바로 (다른 isolate 의 바꾸기가 다음 쪽에서 끝난다)
@override
void cancel(int req) {
if (!_closed) _cancel(_handle, req);
}
@override
void cancelAll() {
if (!_closed) _cancelAll(_handle);
}
/// [QueuedHwpDoc.close] 가 도는 바꾸기가 끝난 뒤에 부른다
@override
Future<void> close() async {
if (_closed) return;
_closed = true;
_close(_handle);
}
}
// ───────── C 함수 (isolate 안에서 부른다 - 핸들은 주소 숫자로 넘긴다) ─────────
typedef _OpenC = Pointer<Void> Function(Pointer<Utf8>, Pointer<Utf8>, Pointer<Int32>, Pointer<Pointer<Utf8>>);
typedef _CountC = Int32 Function(Pointer<Void>);
typedef _CountD = int Function(Pointer<Void>);
typedef _ExportC = Int32 Function(Pointer<Void>, Uint64, Int32, Int32, Pointer<Utf8>, Pointer<Utf8>, Pointer<Int32>, Pointer<Pointer<Utf8>>);
typedef _ExportD = int Function(Pointer<Void>, int, int, int, Pointer<Utf8>, Pointer<Utf8>, Pointer<Int32>, Pointer<Pointer<Utf8>>);
typedef _CancelC = Void Function(Pointer<Void>, Uint64);
typedef _CancelD = void Function(Pointer<Void>, int);
typedef _VoidC = Void Function(Pointer<Void>);
typedef _VoidD = void Function(Pointer<Void>);
typedef _FreeC = Void Function(Pointer<Utf8>);
typedef _FreeD = void Function(Pointer<Utf8>);
DynamicLibrary _lib() => DynamicLibrary.open(RhwpEngine.libName);
/// 돌려받은 오류 글을 읽고 푼다
String? _takeError(DynamicLibrary lib, Pointer<Pointer<Utf8>> err) {
final p = err.value;
if (p == nullptr) return null;
final s = p.toDartString();
lib.lookupFunction<_FreeC, _FreeD>('jj_rhwp_free')(p);
return s;
}
(int, int, String?) _open(String path, String password) {
final lib = _lib();
final open = lib.lookupFunction<_OpenC, _OpenC>('jj_rhwp_open');
final a = path.toNativeUtf8(), b = password.toNativeUtf8();
final code = calloc<Int32>();
final err = calloc<Pointer<Utf8>>();
try {
final doc = open(a, b, code, err);
return (doc.address, code.value, _takeError(lib, err));
} finally {
calloc.free(a);
calloc.free(b);
calloc.free(code);
calloc.free(err);
}
}
int _pageCount(int handle) => _lib().lookupFunction<_CountC, _CountD>('jj_rhwp_page_count')(Pointer<Void>.fromAddress(handle));
(int, String?) _export(int handle, int req, int from, int to, String fontDirs, String out) {
final lib = _lib();
final export = lib.lookupFunction<_ExportC, _ExportD>('jj_rhwp_export_pdf');
final d = fontDirs.toNativeUtf8(), o = out.toNativeUtf8();
final code = calloc<Int32>();
final err = calloc<Pointer<Utf8>>();
try {
final r = export(Pointer<Void>.fromAddress(handle), req, from, to, d, o, code, err);
final e = _takeError(lib, err);
return r == 0 ? (0, null) : (code.value == 0 ? 9 : code.value, e);
} finally {
calloc.free(d);
calloc.free(o);
calloc.free(code);
calloc.free(err);
}
}
void _cancel(int handle, int req) =>
_lib().lookupFunction<_CancelC, _CancelD>('jj_rhwp_cancel')(Pointer<Void>.fromAddress(handle), req);
void _cancelAll(int handle) => _lib().lookupFunction<_VoidC, _VoidD>('jj_rhwp_cancel_all')(Pointer<Void>.fromAddress(handle));
void _close(int handle) => _lib().lookupFunction<_VoidC, _VoidD>('jj_rhwp_close')(Pointer<Void>.fromAddress(handle));

View File

@ -25,7 +25,6 @@ import '../core/ftp.dart' show FtpCertException, FtpRegistry;
import '../core/smb.dart' show SmbRegistry;
import '../core/webdav.dart' show DavRegistry;
import '../platform/android/android_storage.dart';
import '../platform/android/hwp_pdf_ffi.dart' show RhwpEngine;
import 'app_actions.dart';
import 'explorer_look.dart';
import 'key_hint.dart';
@ -41,7 +40,6 @@ import '../platform/windows/recycle_bin.dart';
import '../core/rsync_filter.dart';
import '../core/sync_preview.dart';
import 'path_label.dart';
import 'progress_dialog.dart';
import 'sync_preview_view.dart';
import 'sftp_settings.dart';
import 'ftp_settings.dart';
@ -52,7 +50,6 @@ import '../app/component_store.dart';
import '../app/components.dart';
import 'zip_page.dart';
import '../core/reader_sources.dart';
import '../core/hwp_source.dart';
import '../l10n/tr.dart';
import 'explorer_bookmarks.dart';
import 'explorer_search.dart';
@ -1689,11 +1686,6 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (ext == 'pdf') return _viewPdf(path);
if (zipExtensions.contains(ext)) return _openZip(path, pane);
}
// 관리자 지시 (10/20): Android 의 HWP · HWPX 는 앱 안에서 PDF 로 바꿔 본다 (rhwp - APK 에 들어 있음, 보기 컴포넌트를 켰을 때).
// 열지 못하면 (암호 · 깨진 파일 · 라이브러리 없음) 이유를 알리고 예전처럼 다른 앱으로
if (Platform.isAndroid && c.settings.components.contains('viewer') && hwpExtensions.contains(extOf(path)) && RhwpEngine.available) {
if (await _viewHwp(path)) return;
}
// 문서 미리보기 (Windows: 설치한 변환기로 PDF 로 바꿔 봄)
if (c.settings.components.contains('docs') && Platform.isWindows &&
AppComponent.docExtensions.contains(extOf(path))) {
@ -1769,7 +1761,17 @@ class _ExplorerPageState extends State<ExplorerPage> {
Future<void> _viewDoc(String path) async {
final temp = await c.services.storage.tempDirectory();
if (!mounted) return;
final dialog = showProgressDialog(context, trf('PDF 로 바꾸는 중: {0}', [vBasename(path)]));
final dialog = showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
content: Row(children: [
const CircularProgressIndicator(),
const SizedBox(width: 16),
Expanded(child: Text(trf('PDF 로 바꾸는 중: {0}', [vBasename(path)]))),
]),
),
);
String? pdf;
Object? error;
try {
@ -1778,10 +1780,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
} catch (e) {
error = e;
}
// 사용자가 [취소] · ← 로 그만두었으면 결과를 보이지 않는다 (바꾸기는 끝까지 돌았음)
if (dialog.cancelled()) return;
dialog.close();
await dialog.done;
if (mounted) Navigator.of(context).pop();
await dialog;
if (pdf == null) {
_snack(trf('미리 볼 수 없습니다: {0}', [error]));
return;
@ -1797,37 +1797,6 @@ class _ExplorerPageState extends State<ExplorerPage> {
await openReader(context, c, src);
}
/// 관리자 지시 (10/20): HWP · HWPX → 보기 화면 (보는 쪽부터 몇 쪽씩 PDF 로 바꿈 - [HwpSource]). 열지 못하면 false
Future<bool> _viewHwp(String path) async {
final temp = await c.services.storage.tempDirectory();
if (!mounted) return true;
final dialog = showProgressDialog(context, trf('여는 중: {0}', [vBasename(path)]));
HwpSource? src;
Object? error;
try {
src = await HwpSource.open(path, engine: RhwpEngine(), tempDir: temp, fontDirs: RhwpEngine.fontDirs, log: c.note);
} catch (e) {
error = e;
}
// 사용자가 [취소] · ← 로 그만두었으면 연 문서를 닫고 끝 (다른 앱으로도 열지 않음)
if (dialog.cancelled()) {
await src?.dispose();
return true;
}
dialog.close();
await dialog.done;
if (src == null) {
_snack(trf('앱 안에서 열지 못해 다른 앱으로 엽니다: {0}', [error]));
return false;
}
if (!mounted) {
await src.dispose();
return true;
}
await openReader(context, c, src);
return true;
}
Future<void> _viewPdf(String path) async {
final temp = await c.services.storage.tempDirectory();
PdfSource src;
@ -3544,7 +3513,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
// 72: 무엇이 바뀌는지 파일별로 (→ · ← · 받는 쪽에만 있음 · 지워짐)
const SizedBox(height: 8),
if (badOptions().isNotEmpty) OptionProblemNote(badOptions()),
if (filterLimits().isNotEmpty)
// 관리자 지시 (10/20): [실행] 이 막힐 때는 막히는 이유 한 줄만 (미리 볼 수 없음 · [알고 실행합니다] 는 숨김)
if (badOptions().isEmpty && filterLimits().isNotEmpty)
FilterLimitNote(unsupported: filterLimits(), deletes: deletes(), ack: filterAck, onAck: (v) => set(() => filterAck = v)),
SyncPreviewView(
// 옵션이 바뀌면 (원본 파일 지우기) 다시 비교

View File

@ -40,8 +40,6 @@ List<(List<String>, String)> get _licenseAssets => [
(['smbj 0.15.0 · asn-one · Bouncy Castle · SLF4J · MBassador (Android SMB)'], 'assets/licenses/SMBJ_LICENSES.txt'),
// Tor 브라우저: tor 와 함께 묶인 Libevent · OpenSSL · zlib · zstd (공식 번들의 docs)
(['Tor 0.4.9.13 (Libevent · OpenSSL · zlib · Zstandard)'], 'assets/licenses/TOR_LICENSES.txt'),
// 관리자 지시 (10/20): Android 의 HWP · HWPX 보기 (libjj_rhwp.so - rhwp MIT 와 함께 묶인 Rust crate 들)
if (Platform.isAndroid) (['rhwp 0.8.7 (HWP · HWPX → PDF)'], 'assets/licenses/RHWP_LICENSES.txt'),
// 175: 함께 빌드한 네이티브 라이브러리 (Dart 쪽 패키지 라이선스와 따로)
([tr('whisper.cpp · ggml (whisper_ggml 안)')], 'assets/licenses/WHISPER_CPP_LICENSE.txt'),
(['ONNX Runtime 1.15.1 (Microsoft)'], 'assets/licenses/ONNXRUNTIME_LICENSES.txt'),

View File

@ -698,7 +698,8 @@ class _CopyCardState extends State<_CopyCard> {
TextButton.icon(onPressed: () => center.cancel(t.id), icon: const Icon(Icons.stop, size: 18), label: Text(tr('취소')))
else
FilledButton.icon(
onPressed: () => _runPressed(t),
// 관리자 지시 (10/20, 28 지적): 옵션이 막히면 (빨간 안내가 보이면) 확인 창처럼 [실행] 도 끈다 - 누르면 실패로 끝났다
onPressed: _method == 'rsync' && optionProblems(_options.text).isNotEmpty ? null : () => _runPressed(t),
icon: const Icon(Icons.play_arrow, size: 18),
label: Text(t.lastResult == 'cancelled' || t.lastResult == 'failed' ? tr('재개') : tr('실행')),
),

View File

@ -1,42 +0,0 @@
import 'package:flutter/material.dart';
import '../l10n/tr.dart';
/// 바꾸는 · 여는 동안의 진행 창 ([취소] 있음). 관리자 지시 (10/20, 28 지적): Android ← · [취소] 로 창이 먼저 닫힐 수 있어,
/// 일이 끝난 뒤에는 그 창이 아직 열려 있을 때만 닫는다 (예전에는 끝난 뒤의 pop() 이 다른 화면을 닫을 수 있었다).
/// - [ProgressDialog.cancelled]: 사용자가 [취소] · ← 로 그만두었는지 (그러면 결과를 보이지 않는다)
/// - [ProgressDialog.close]: 일이 끝나 창을 닫는다 (이미 닫혔으면 아무것도 하지 않는다)
class ProgressDialog {
final Future<void> done;
final bool Function() cancelled;
final void Function() close;
const ProgressDialog._(this.done, this.cancelled, this.close);
}
ProgressDialog showProgressDialog(BuildContext context, String text) {
BuildContext? dctx;
var open = true;
var byUser = true;
final done = showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) {
dctx = ctx;
return AlertDialog(
content: Row(children: [
const CircularProgressIndicator(),
const SizedBox(width: 16),
Expanded(child: Text(text)),
]),
actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(tr('취소')))],
);
},
).whenComplete(() => open = false);
return ProgressDialog._(done, () => !open && byUser, () {
if (!open) return;
byUser = false;
final x = dctx;
// 그 창이 맨 위일 때만 (28 지적: 이론상 그 위에 다른 화면이 올라와 있으면 그것을 닫을 수 있다)
if (x != null && x.mounted && (ModalRoute.of(x)?.isCurrent ?? false)) Navigator.of(x).pop();
});
}

View File

@ -7,7 +7,6 @@ import 'package:wakelock_plus/wakelock_plus.dart';
import '../app/app_controller.dart';
import '../core/reader_sources.dart';
import '../core/hwp_source.dart' show HwpPdfException, HwpSource;
import '../core/vfs.dart';
import '../l10n/tr.dart';
import 'ai_image_page.dart';
@ -160,8 +159,7 @@ class _ReaderPageState extends State<ReaderPage> {
if (d == null) return false;
Size turned(Size s) => _turns.isOdd ? Size(s.height, s.width) : s;
return autoSplitsPage(turned(d), [for (final e in _dims.entries) if (e.key != page) turned(e.value)],
// 관리자 지시 (10/20) · 내 판단: HWP · HWPX 문서의 가로 쪽 (가로 표 등) 은 만화 두 쪽 스캔이 아니라 나누지 않는다
bundle: src is! ImageFilesSource && src is! HwpSource);
bundle: src is! ImageFilesSource);
}
/// 223: 나눠 본 장에서 바로 한 장으로 (설정을 "끄기" 로)
@ -441,35 +439,10 @@ class _ReaderPageState extends State<ReaderPage> {
final fit = c.settings.readerFit;
final slot = _v[i];
final half = slot.part >= 0;
// 관리자 지시 (10/20, HWP 2단계): 이유와 [다시 시도] - 그 장의 기억을 지우고 다시 부른다 (시간 제한을 넘긴 HWP 묶음 등)
Widget failed(Object? e) => SizedBox(
width: 320,
height: 160,
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Text(trf('열 수 없습니다: {0}', [e]), textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 8),
// 관리자 지시 (10/20, 28 지적): 변환기 원문은 화면 글 대신 [자세히] 에
if (e is HwpPdfException && e.detail.isNotEmpty)
TextButton(
onPressed: () => showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
content: SelectableText(e.detail, style: const TextStyle(fontSize: 12)),
actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(tr('닫기')))],
),
),
child: Text(tr('자세히')),
),
OutlinedButton(
onPressed: () => setState(() {
_cache.remove(slot.page);
_cacheHalf.remove(slot.page);
_sharp.remove(i);
_sharpWidth.remove(i);
}),
child: Text(tr('다시 시도')),
),
]));
height: 120,
child: Center(child: Text(trf('열 수 없습니다: {0}', [e]), style: const TextStyle(color: Colors.white70))));
// 확대해 다시 그린 것이 있으면 그것 (다 그릴 때까지는 앞의 그림을 그대로 보인다)
final page = FutureBuilder<ReaderImage>(
key: ValueKey('$_version:${slot.page}:${slot.part}'),

View File

@ -258,7 +258,8 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
const SizedBox(height: 8),
// 15 검토 F2: 미리 볼 수 없는 거르기 규칙 (늘 같은 자리 - 비교 결과와 상관없이 옵션으로 정해짐)
if (bad.isNotEmpty) OptionProblemNote(bad),
if (filter.unsupported.isNotEmpty)
// 관리자 지시 (10/20): [실행] 이 막힐 때는 막히는 이유 한 줄만 (미리 볼 수 없음 · [알고 실행합니다] 는 숨김)
if (bad.isEmpty && filter.unsupported.isNotEmpty)
FilterLimitNote(unsupported: filter.unsupported, deletes: deletes, ack: filterAck, onAck: (v) => set(() => filterAck = v)),
SyncPreviewView(
compute: () async => [

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 2026.10.18+1
version: 2026.10.20+1
environment:
sdk: ^3.13.4

View File

@ -219,6 +219,26 @@ void main() {
await tester.pumpAndSettle();
});
testWidgets('관리자 지시 (10/20): [실행] 이 막힐 때는 막히는 이유 한 줄만 - "미리 볼 수 없습니다" · [알고 실행합니다] 는 숨김', (tester) async {
final src = p.join(tmp.path, 'bs'), dst = p.join(tmp.path, 'bd');
File(p.join(src, 'a.txt'))
..createSync(recursive: true)
..writeAsStringSync('a');
Directory(dst).createSync(recursive: true);
bool? r;
// 미리 볼 수 없는 규칙 (merge) + 알아볼 수 없는 낱말 (delete) - 실행은 막힘
final t = CopyTask(id: '9', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete --filter="merge C:/jj_rules/rules.txt" delete');
await host(tester, (ctx) async => r = await confirmDeletingRun(ctx, t));
await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 200);
expect(find.textContaining('옵션을 알아볼 수 없습니다'), findsOneWidget);
expect(find.textContaining('미리 볼 수 없습니다'), findsNothing);
expect(find.text('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)'), findsNothing);
expect(tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행')).onPressed, isNull);
await tester.tap(find.text('취소').last);
await tester.pumpAndSettle();
expect(r, isFalse);
});
testWidgets('177 · 15 검토 F2: 미리 볼 수 없는 거르기 규칙 + --delete 면 빨간 경고 · 체크해야 [실행] 이 켜진다', (tester) async {
final src = p.join(tmp.path, 'fs'), dst = p.join(tmp.path, 'fd');
File(p.join(src, 'a.txt'))

View File

@ -22,6 +22,9 @@ import 'package:jj_mkvmaker/services/platform_services.dart';
/// l (관리자 지시 10/17): FTP · FTPS (dart:io 로 직접). 실제 서버 시험은 tool/ftp_test_server.py (pyftpdlib) -
/// 시험용 파이썬 (sftp 시험과 같은 venv) 에 pyftpdlib 가 없으면 건너뛴다
/// 시험 서버 비밀번호 - 무작위 주소에 우연히 들어가지 않게 길고 드물게 (관리자 지시 10/20, 28 이 찾음)
const ftpPw = 'ftp-Pw-7Rk2xQ9t';
void main() {
final hasPy = File(sftpTestPython).existsSync();
@ -76,7 +79,8 @@ void main() {
late String caFp;
Future<void> start(String mode, {bool chain = false}) async {
final pr = await Process.start(sftpTestPython, ['tool/ftp_test_server.py', '--root', root.path, '--mode', mode, if (chain) '--chain']);
final pr = await Process.start(
sftpTestPython, ['tool/ftp_test_server.py', '--root', root.path, '--mode', mode, '--password', ftpPw, if (chain) '--chain']);
proc = pr;
final lines = pr.stdout.transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream();
pr.stderr.drain<void>();
@ -111,7 +115,7 @@ void main() {
}
});
FtpServer srv({String security = 'explicit', String cert = '', String password = 'pw'}) =>
FtpServer srv({String security = 'explicit', String cert = '', String password = ftpPw}) =>
FtpServer(id: 'f1', name: 'test', host: '127.0.0.1', port: port, user: 'jj', password: password, security: security, certFingerprint: cert);
Future<Object?> err(Future<void> Function() f) async {
@ -247,13 +251,55 @@ void main() {
FtpRegistry.configure([srv(security: 'none')]);
final src = p.join(tmp.path, 'local', 'scene_src'), want = p.join(tmp.path, 'local', 'scene_rsync'), got = p.join(root.path, 'scene');
final bad = <String>[];
// 관리자 지시 (10/20): 가끔 실패해 원인을 보려고, 실패하면 옵션 · 걸린 시간 · 포트 · 예외 · 다른 점과
// 보내는 쪽 · 맞추기 전 앱 쪽 · rsync 쪽 · 앱 쪽 목록 (파일은 크기 · 바뀐 시각 ms) 을 한 번에 보인다
String listing(String dir) {
final root = Directory(dir);
if (!root.existsSync()) return ' (없음)';
final rows = <String>[];
for (final e in root.listSync(recursive: true)) {
final rel = p.relative(e.path, from: dir).replaceAll(r'\', '/');
if (e is Directory) {
rows.add('$rel/');
} else {
final s = e.statSync();
rows.add('$rel ${s.size}B ${s.modified.toIso8601String()}');
}
}
return (rows..sort()).map((r) => ' $r').join('\n');
}
final sw = Stopwatch()..start();
for (final opts in sceneOptions) {
final t0 = sw.elapsedMilliseconds;
writeScene(src, want);
final w = await realRsyncTree(rsync, src, want, opts);
final wantList = listing(want);
writeScene(src, got);
await FileOps().mirror(src, 'ftp://f1/scene', delete: optionsDelete(opts), filter: RsyncFilter.parse(opts));
final beforeList = listing(got);
Object? error;
try {
await FileOps().mirror(src, 'ftp://f1/scene', delete: optionsDelete(opts), filter: RsyncFilter.parse(opts));
} catch (e, st) {
error = '$e\n$st';
}
final d = treeDiff(w, treeOf(got));
if (d.isNotEmpty) bad.add('$opts\n ${d.join('\n ')}');
if (d.isNotEmpty || error != null) {
bad.add([
'옵션: $opts (${sw.elapsedMilliseconds - t0}ms, 시험 시작부터 ${t0}ms, 포트 $port, ${DateTime.now().toIso8601String()})',
if (error != null) ' 예외: $error',
' 다른 점:',
...d.map((x) => ' $x'),
' 보내는 쪽:',
listing(src),
' 맞추기 전 앱 쪽:',
beforeList,
' rsync 쪽:',
wantList,
' 앱 쪽:',
listing(got),
].join('\n'));
}
}
expect(bad, isEmpty, reason: bad.join('\n'));
}, skip: hasPy ? false : '시험용 파이썬 없음', timeout: const Timeout(Duration(minutes: 5)));
@ -285,7 +331,10 @@ void main() {
final bridge = await RemoteStreamBridge.get();
final u1 = bridge.url('ftp://f1/영상/1화.mkv'), u2 = bridge.url('ftp://f1/영상/2화.mkv');
expect(u1, startsWith('http://127.0.0.1:'));
expect(u1, isNot(contains('pw')), reason: '주소에 비밀번호 없음');
// 관리자 지시 (10/20, 28 이 찾음): 시험 비밀번호가 두 글자 "pw" 라 다리 주소의 무작위 표 (예 …-4pwvUR-…) 에
// 우연히 들어가 "주소에 비밀번호 없음" 이 가끔 실패했다 (비밀번호가 샌 것이 아님) - 길고 드문 비밀번호로 보고, 주소의 사용자 정보도 따로
expect(u1, isNot(contains(ftpPw)), reason: '주소에 비밀번호 없음');
expect(Uri.parse(u1).userInfo, isEmpty, reason: '주소에 사용자 정보 없음');
Future<(int, String)> get(String url, [String? range]) async {
final http = HttpClient();
try {

View File

@ -1,30 +0,0 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/platform/android/hwp_pdf_ffi.dart';
/// 관리자 지시 (10/20, 28 지적): 옛 libjj_rhwp.so (ABI 판 1 - jj_rhwp_cancel · jj_rhwp_abi_version 없음, 오류 code 칸 없음) 이면
/// 앱 안 보기를 쓰지 않고 다른 앱으로 연다 (새 인수를 넘기면 힙이 망가진다)
void main() {
const v1 = {'jj_rhwp_open', 'jj_rhwp_page_count', 'jj_rhwp_export_pdf', 'jj_rhwp_close', 'jj_rhwp_free', 'jj_rhwp_version'};
final v2 = {...v1, 'jj_rhwp_cancel', 'jj_rhwp_abi_version'};
final v3 = {...v2, 'jj_rhwp_cancel_all'};
test('옛 .so (기호 없음) · 판이 다름 · 부르다 실패 → 쓰지 않음, 맞으면 씀', () {
expect(RhwpEngine.abiCompatible(has: v1.contains, abi: () => throw ArgumentError('no symbol')), isFalse, reason: '판 1');
expect(RhwpEngine.abiCompatible(has: {...v1, 'jj_rhwp_abi_version'}.contains, abi: () => 2), isFalse, reason: 'cancel 없음');
expect(RhwpEngine.abiCompatible(has: v2.contains, abi: () => 2), isFalse, reason: '판 2 (cancel_all 없음 - 문서 하나의 멈춤 깃발)');
expect(RhwpEngine.abiCompatible(has: v3.contains, abi: () => 2), isFalse, reason: '판이 다름');
expect(RhwpEngine.abiCompatible(has: v3.contains, abi: () => 4), isFalse, reason: '더 새 판도');
expect(RhwpEngine.abiCompatible(has: (_) => throw StateError('x'), abi: () => 2), isFalse);
expect(RhwpEngine.abiCompatible(has: v3.contains, abi: () => RhwpEngine.abiVersion), isTrue);
});
test('Dart 의 판 = tool/rhwp_ffi 의 JJ_RHWP_ABI_VERSION · 꼭 있어야 하는 함수는 lib.rs 에 모두', () {
final rs = File('tool/rhwp_ffi/src/lib.rs').readAsStringSync();
expect(rs, contains('pub const JJ_RHWP_ABI_VERSION: c_int = ${RhwpEngine.abiVersion};'));
for (final s in RhwpEngine.requiredSymbols) {
expect(rs, contains('pub extern "C" fn $s('), reason: s);
}
});
}

View File

@ -1,316 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/hwp_source.dart';
import 'package:jj_mkvmaker/core/reader_sources.dart';
import 'package:path/path.dart' as p;
/// 쪽 범위를 글로 적은 파일을 "PDF" 로 만드는 가짜 엔진 (rhwp 대신) - 부른 범위를 적어 둔다.
/// 문서는 앱과 같은 [QueuedHwpDoc] (줄 세우기 · 시간 제한 · 멈춤) 위의 가짜 C 함수 ([_Native])
class _Engine implements HwpPdfEngine {
final int pages;
final calls = <(int, int)>[];
/// 있으면 바꾸기가 이것을 기다린다 (멈춤 · 닫기를 받으면 바로 끝남 - Rust 가 쪽마다 멈춤 표시를 보는 것처럼)
Completer<void>? hold;
var closed = false;
final cancelled = <int>{};
var cancelledAll = false;
_Engine(this.pages);
@override
Future<HwpPdfDoc> open(String path, {String? password}) async => QueuedHwpDoc(_Native(this));
}
class _Native implements HwpNative {
final _Engine e;
_Native(this.e);
@override
int get pageCount => e.pages;
@override
Future<(int, String?)> export(int req, int from, int to, String fontDirs, String out) async {
e.calls.add((from, to));
final h = e.hold;
if (h != null) await h.future;
if (e.cancelledAll || e.cancelled.contains(req)) return (1, 'cancelled');
File(out).writeAsStringSync('$from-$to');
return (0, null);
}
void _wake() {
final h = e.hold;
if (h != null && !h.isCompleted) h.complete();
}
@override
void cancel(int req) {
e.cancelled.add(req);
_wake();
}
@override
void cancelAll() {
e.cancelledAll = true;
_wake();
}
@override
Future<void> close() async => e.closed = true;
}
/// 파일을 읽어 쪽 수를 아는 가짜 묶음 (PDFium 없이) - 연 것 · 닫은 것을 적어 둔다
class _Opener {
final opened = <String>[];
final disposed = <String>[];
Completer<void>? renderHold;
Future<HwpChunk> call(String path) async {
opened.add(p.basename(path));
final parts = File(path).readAsStringSync().split('-').map(int.parse).toList();
return _Chunk(this, p.basename(path), parts[1] - parts[0]);
}
}
class _Chunk implements HwpChunk {
final _Opener o;
final String name;
@override
final int pageCount;
_Chunk(this.o, this.name, this.pageCount);
@override
Future<ReaderImage> render(int index, {required int maxWidth}) async {
expect(index, lessThan(pageCount));
final h = o.renderHold;
if (h != null) await h.future;
return const ReaderImage();
}
@override
Future<void> dispose() async => o.disposed.add(name);
}
/// 관리자 지시 (10/20, HWP 2단계): 보기 원본 HwpSource 의 흐름 - 묶음 나누기 · 미리 만들기 · 열어 두는 묶음 상한 (메모리) ·
/// 그리는 묶음은 닫지 않음 · 닫은 묶음은 다시 바꾸지 않고 파일만 · 시간 제한 → 이유 · 다시 시도 · 닫기
void main() {
late Directory dir;
late File doc;
setUp(() {
dir = Directory.systemTemp.createTempSync('jj_hwpunit_');
doc = File(p.join(dir.path, 'a.hwp'))..writeAsStringSync('x');
});
tearDown(() {
if (dir.existsSync()) dir.deleteSync(recursive: true);
});
Future<void> settle() => Future<void>.delayed(const Duration(milliseconds: 50));
test('보는 쪽의 묶음 + 다음 묶음만 · 열어 둔 묶음은 3개까지 (오래된 것부터 닫음) · 다시 보면 파일만 연다', () async {
final e = _Engine(40), o = _Opener();
final s = await HwpSource.open(doc.path, engine: e, tempDir: dir.path, chunk: 5, maxOpenChunks: 3, opener: o.call);
expect(s.length, 40);
await s.load(0, maxWidth: 100);
await settle();
expect(e.calls, [(0, 5), (5, 10)]);
expect(s.openChunks, [0, 1]);
await s.load(12, maxWidth: 100); // 묶음 2 + 미리 3 → 4개 → 0 을 닫음
await settle();
expect(e.calls, [(0, 5), (5, 10), (10, 15), (15, 20)]);
expect(s.openChunks, [1, 2, 3]);
expect(o.disposed, ['p0.pdf']);
// 닫은 묶음 0 을 다시: 바꾸지 않고 (calls 그대로) 파일만 연다
await s.load(1, maxWidth: 100);
await settle();
expect(e.calls.where((c) => c == (0, 5)), hasLength(1));
expect(o.opened.where((n) => n == 'p0.pdf'), hasLength(2));
expect(s.openChunks.length, lessThanOrEqualTo(3));
expect(s.madeChunks, containsAll([0, 1, 2, 3]));
// 끝 묶음은 남은 쪽만
await s.load(39, maxWidth: 100);
expect(e.calls.last, (35, 40));
await s.dispose();
expect(e.closed, isTrue);
expect(s.work.existsSync(), isFalse, reason: '닫으면 만든 PDF 들을 지움');
});
test('그리고 있는 묶음은 상한을 넘어도 닫지 않고, 다 그린 뒤에 닫는다', () async {
final e = _Engine(40), o = _Opener();
final s = await HwpSource.open(doc.path, engine: e, tempDir: dir.path, chunk: 5, maxOpenChunks: 2, opener: o.call);
final hold = Completer<void>();
o.renderHold = hold;
final drawing = s.load(2, maxWidth: 100); // 묶음 0 을 그리는 중
await settle();
o.renderHold = null;
await s.load(20, maxWidth: 100); // 묶음 4 (+ 5)
await s.load(30, maxWidth: 100); // 묶음 6 (+ 7)
await settle();
expect(o.disposed, isNot(contains('p0.pdf')), reason: '그리는 중');
expect(s.openChunks, contains(0));
hold.complete();
await drawing;
await s.load(35, maxWidth: 100);
await settle();
expect(o.disposed, contains('p0.pdf'), reason: '다 그린 뒤에는 상한에 맞춰 닫힘');
expect(s.openChunks.length, lessThanOrEqualTo(2));
await s.dispose();
});
test('시간 제한을 넘기면 쪽 범위 · 초가 든 이유, 다시 부르면 다시 만든다', () async {
final e = _Engine(8)..hold = Completer<void>();
final o = _Opener();
final s = await HwpSource.open(doc.path, engine: e, tempDir: dir.path, chunk: 5, timeout: const Duration(milliseconds: 200),
opener: o.call);
Object? err;
try {
await s.load(0, maxWidth: 100);
} catch (x) {
err = x;
}
expect(err, isA<HwpPdfException>());
expect((err! as HwpPdfException).kind, HwpErrorKind.timeout);
expect('$err', allOf(contains('1~5'), contains('0초')));
expect(e.cancelled, {1}, reason: '시간 제한을 넘기면 그 요청의 변환도 멈춘다 (기다림만 끊지 않음)');
expect(s.openChunks, isNot(contains(0)), reason: '실패한 묶음은 기억하지 않음 - [다시 시도] 가 다시 만든다');
e.hold = null; // 멈춤이 기다림을 깨웠다
await s.load(0, maxWidth: 100);
expect(e.calls.where((c) => c == (0, 5)).length, greaterThanOrEqualTo(2));
await s.dispose();
});
test('닫으면 만드는 중인 묶음은 끝난 뒤 스스로 닫히고 더 만들지 않는다', () async {
final e = _Engine(10)..hold = Completer<void>();
final o = _Opener();
final s = await HwpSource.open(doc.path, engine: e, tempDir: dir.path, chunk: 5, opener: o.call);
final pending = s.load(0, maxWidth: 100).then<Object?>((_) => null, onError: (Object x) => x);
await settle();
await s.dispose(); // 도는 바꾸기는 멈춤으로 곧 끝나고, 닫힌다
expect(e.cancelledAll, isTrue, reason: '닫으면 바꾸던 것을 멈춘다');
expect(e.closed, isTrue);
final r = await pending;
expect(r, isA<StateError>(), reason: '닫힌 문서');
expect(o.opened, isEmpty, reason: '닫은 뒤에는 PDF 를 열지 않음');
});
test('앱이 켜질 때 남은 HWP 작업 폴더 (jj_hwp_) 만 지운다 - 막 생긴 것 · 다른 이름은 남김', () {
final old = Directory(p.join(dir.path, 'jj_hwp_1'))..createSync();
File(p.join(old.path, 'p0.pdf')).writeAsStringSync('x');
final fresh = Directory(p.join(dir.path, 'jj_hwp_2'))..createSync();
final other = Directory(p.join(dir.path, 'jj_hwpx_keep'))..createSync();
final file = File(p.join(dir.path, 'jj_hwp_file'))..writeAsStringSync('x');
// 5분 뒤로 보면 old · fresh 둘 다 1분이 지났다 - fresh 는 지금 기준으로는 남는다
expect(removeStaleHwpWork(dir.path), isEmpty, reason: '모두 막 생김');
final gone = removeStaleHwpWork(dir.path, now: DateTime.now().add(const Duration(minutes: 5)));
expect(gone..sort(), ['jj_hwp_1', 'jj_hwp_2']);
expect(old.existsSync() || fresh.existsSync(), isFalse);
expect(other.existsSync(), isTrue, reason: '이름이 jj_hwp_ 로 시작하지 않음 (jj_hwpx_…)');
expect(file.existsSync(), isTrue, reason: '폴더만');
});
test('변환기 오류 코드 → 종류 · 화면 글 (tool/rhwp_ffi 의 JJ_RHWP_E_* 순서), 원문은 detail 에만', () {
expect([for (var c = 1; c <= 9; c++) hwpErrorKindOf(c)], [
HwpErrorKind.cancelled, HwpErrorKind.read, HwpErrorKind.invalid, HwpErrorKind.passwordRequired, HwpErrorKind.passwordWrong,
HwpErrorKind.range, HwpErrorKind.render, HwpErrorKind.write, HwpErrorKind.internal, //
]);
expect(hwpErrorKindOf(0), HwpErrorKind.internal);
expect(hwpErrorKindOf(42), HwpErrorKind.internal);
const e = HwpPdfException(HwpErrorKind.passwordRequired, '비밀번호가 필요한 암호 문서입니다 (parse_document_with_password …)');
expect('$e', '암호가 걸린 문서입니다', reason: '화면에는 원문 대신 번역 글');
expect(e.detail, contains('parse_document_with_password'));
// 원본 lib.rs 의 코드 값과 같은지 (순서가 바뀌면 화면 글이 틀린다)
final rs = File('tool/rhwp_ffi/src/lib.rs').readAsStringSync();
const names = ['CANCELLED', 'READ', 'INVALID', 'PASSWORD_REQUIRED', 'PASSWORD_WRONG', 'RANGE', 'RENDER', 'WRITE', 'INTERNAL'];
for (final (i, n) in names.indexed) {
expect(rs, contains('pub const JJ_RHWP_E_$n: c_int = ${i + 1};'), reason: n);
}
});
group('QueuedHwpDoc (28 지적: 시간 제한 · 멈춤은 요청마다)', () {
test('앞 묶음이 오래 돌아도, 줄에서 기다린 뒤 묶음의 시간 제한은 제 시작부터 잰다', () async {
final n = _Gated();
final d = QueuedHwpDoc(n);
final a = d.exportPdf(0, 5, 'a', timeout: const Duration(seconds: 30)); // 오래 도는 앞 묶음
final b = d.exportPdf(5, 10, 'b', timeout: const Duration(milliseconds: 300));
await Future<void>.delayed(const Duration(milliseconds: 700)); // 뒤 묶음의 제한보다 오래 줄에서 기다림
expect(n.started, [1]);
n.release(1);
await a;
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(n.started, [1, 2]);
n.release(2);
await b; // 시간 제한 없이 끝남
expect(n.cancelled, isEmpty);
});
test('한 요청이 시간을 넘기면 그 요청만 멈추고, 멈춘 것이 끝난 뒤 다음 것이 돈다', () async {
final n = _Gated();
final d = QueuedHwpDoc(n);
Object? err;
final a = d.exportPdf(0, 5, 'a', timeout: const Duration(milliseconds: 200)).catchError((Object e) => err = e);
final b = d.exportPdf(5, 10, 'b');
await a;
expect(err, isA<HwpPdfException>().having((e) => e.kind, 'kind', HwpErrorKind.timeout));
expect(n.cancelled, {1}, reason: '다른 요청 (2) 은 멈추지 않음');
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(n.started, [1, 2], reason: '멈춘 1 이 끝난 뒤에 2 가 시작');
n.release(2);
await b;
});
test('닫으면 도는 것은 멈추고 줄에 선 것은 시작하지 않는다 (닫기 요청이 지워지지 않음)', () async {
final n = _Gated();
final d = QueuedHwpDoc(n);
final a = d.exportPdf(0, 5, 'a').then<Object?>((_) => null, onError: (Object e) => e);
final b = d.exportPdf(5, 10, 'b').then<Object?>((_) => null, onError: (Object e) => e);
await Future<void>.delayed(const Duration(milliseconds: 50));
await d.close();
expect(n.all, isTrue);
expect(n.started, [1], reason: '줄에 선 2 는 시작하지 않음');
expect(n.closed, isTrue);
expect(await a, isA<HwpPdfException>().having((e) => e.kind, 'kind', HwpErrorKind.closed));
expect(await b, isA<HwpPdfException>().having((e) => e.kind, 'kind', HwpErrorKind.closed));
expect(() => d.exportPdf(0, 1, 'c'), throwsA(isA<HwpPdfException>()), reason: '닫은 뒤의 요청');
});
});
}
/// 요청마다 문이 있는 가짜 C 함수 - 멈춤 · 닫기를 받으면 문이 열리고 (cancelled) 로 끝난다
class _Gated implements HwpNative {
final started = <int>[];
final gates = <int, Completer<void>>{};
final cancelled = <int>{};
var all = false;
var closed = false;
@override
int get pageCount => 10;
Completer<void> _gate(int req) => gates.putIfAbsent(req, Completer<void>.new);
void release(int req) {
if (!_gate(req).isCompleted) _gate(req).complete();
}
@override
Future<(int, String?)> export(int req, int from, int to, String fontDirs, String out) async {
started.add(req);
await _gate(req).future;
return all || cancelled.contains(req) ? (1, 'cancelled') : (0, null);
}
@override
void cancel(int req) {
cancelled.add(req);
release(req);
}
@override
void cancelAll() {
all = true;
for (final r in [...gates.keys, ...started]) {
release(r);
}
}
@override
Future<void> close() async => closed = true;
}

View File

@ -165,30 +165,4 @@ void main() {
expect(a, contains('[패키지 라이선스]'));
expect(a, contains('GPLv3'));
});
// 관리자 지시 (10/20, 28 지적): Android HWP 보기 (libjj_rhwp.so) - 실제로 링크되는 Rust crate 마다 라이선스 · 원문
// (tool/licenses/rhwp_crates.py 를 tool/build_rhwp_android.sh 가 부름). Cargo.lock 이 바뀌었는데 다시 만들지 않으면 여기서 잡힌다
test('rhwp: Cargo.lock 의 모든 crate 가 "linked" 나 "not linked" 에 있고, 링크되는 crate 마다 원문 절', () {
final t = File('assets/licenses/RHWP_LICENSES.txt').readAsStringSync().replaceAll('\r\n', '\n');
final lock = File('tool/rhwp_ffi/Cargo.lock').readAsStringSync().replaceAll('\r\n', '\n');
final locked = {
for (final m in RegExp(r'\[\[package\]\]\s*\nname = "([^"]+)"\s*\nversion = "([^"]+)"').allMatches(lock)) '${m[1]} ${m[2]}'
};
final linked = {for (final m in RegExp(r'^linked: (\S+) (\S+) ', multiLine: true).allMatches(t)) '${m[1]} ${m[2]}'};
final notLinked = {for (final m in RegExp(r'^not linked: (\S+) (\S+)$', multiLine: true).allMatches(t)) '${m[1]} ${m[2]}'};
expect(linked, isNotEmpty);
expect(linked.intersection(notLinked), isEmpty);
expect({...linked, ...notLinked, 'jj_rhwp 0.1.0'}, locked, reason: 'Cargo.lock 이 바뀌면 sh tool/build_rhwp_android.sh 로 다시 만든다');
for (final c in linked) {
expect(t, contains('=== $c ('), reason: '$c 의 라이선스 원문 절');
}
// 28 이 짚은 것들 (Cargo.lock 에 있으면 어느 한쪽 목록에 - 링크되는지는 cargo tree 가 정함)
for (final n in ['tiny-skia', 'zlib-rs', 'curve25519-dalek', 'unicode-ident', 'rustybuzz', 'skrifa', 'miniz_oxide', 'fontdb']) {
for (final c in locked.where((c) => c.startsWith('$n '))) {
expect(linked.contains(c) || notLinked.contains(c), isTrue, reason: c);
}
}
expect(t, contains('rhwp v0.8.7'));
expect(t, contains('1a76570e833917d15817415a53c09ad61ab3203f'));
});
}

View File

@ -329,6 +329,26 @@ void main() {
expect(find.text('복사 · rsync 로 이동'), findsOneWidget);
});
testWidgets('관리자 지시 (10/20, 28 지적): 옵션이 막히면 (빨간 안내) 카드의 [▶ 실행] 도 꺼진다', (tester) async {
final tmp = Directory.systemTemp.createTempSync('jj_monbad_');
addTearDown(() => tmp.deleteSync(recursive: true));
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), method: 'rsync', options: '-av delete')];
tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(home: MonitorPage(c: c)));
await tester.pump();
FilledButton run() => tester.widget<FilledButton>(find.ancestor(of: find.text('실행'), matching: find.byWidgetPredicate((w) => w is FilledButton)));
expect(find.textContaining('옵션을 알아볼 수 없습니다'), findsOneWidget);
expect(run().onPressed, isNull, reason: '막히면 꺼짐');
// 옵션을 고치면 다시 켜진다
await tester.enterText(find.widgetWithText(TextField, '옵션'), '-av');
await tester.pump();
expect(find.textContaining('옵션을 알아볼 수 없습니다'), findsNothing);
expect(run().onPressed, isNotNull);
});
testWidgets('233: 이동 카드의 저장 창은 "이 이동을" · 229 설정은 Rsync 탭에도 있다', (tester) async {
final tmp = Directory.systemTemp.createTempSync('jj_mon233_');
addTearDown(() => tmp.deleteSync(recursive: true));

View File

@ -117,7 +117,7 @@ void main() {
expect(hint('-a --exclude="a.tmp'), '따옴표를 닫아 주세요');
expect(hint('-a delete'), '옵션은 - 나 -- 로 시작합니다');
expect(hint('-a --exclude'), '값을 적어 주세요 (예: --exclude=*.tmp)');
expect(hint('-a ---delete'), '곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요');
expect(hint('-a ---delete'), '옵션은 - 나 -- 로 시작합니다', reason: '관리자 지시 (10/20)');
expect(hint('-a \u00ABx\u00BB'), '곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요');
expect(hint('-a delete --exclude'), '옵션은 - 나 -- 로 시작합니다 · 값을 적어 주세요 (예: --exclude=*.tmp)');
expect(optionProblemMessage(optionProblems('-a delete')), '옵션을 알아볼 수 없습니다: 경로로 읽힙니다: delete - 옵션은 - 나 -- 로 시작합니다');

View File

@ -1,59 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/ui/progress_dialog.dart';
/// 관리자 지시 (10/20, 28 지적): 진행 창이 ← · [취소] 로 먼저 닫혀도, 일이 끝난 뒤 close() 가 아래 화면을 닫지 않는다
void main() {
Future<BuildContext> pushPage(WidgetTester tester) async {
late BuildContext page;
await tester.pumpWidget(MaterialApp(home: Builder(builder: (ctx) {
return Scaffold(
body: TextButton(
onPressed: () => Navigator.of(ctx).push(MaterialPageRoute<void>(builder: (c) {
page = c;
return const Scaffold(body: Text('아래 화면'));
})),
child: const Text('열기'),
),
);
})));
await tester.tap(find.text('열기'));
await tester.pumpAndSettle();
return page;
}
testWidgets('← 로 닫으면 그만둔 것 - 끝난 뒤 close() 는 아래 화면을 닫지 않는다', (tester) async {
final page = await pushPage(tester);
final d = showProgressDialog(page, '여는 중: a.hwp');
await tester.pump();
expect(find.text('여는 중: a.hwp'), findsOneWidget);
expect(d.cancelled(), isFalse);
// Android ← (창을 닫음)
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('여는 중: a.hwp'), findsNothing);
expect(d.cancelled(), isTrue);
d.close(); // 일이 끝남 - 이미 닫혔으니 아무것도 닫지 않는다
await tester.pumpAndSettle();
expect(find.text('아래 화면'), findsOneWidget, reason: '예전에는 이 pop() 이 아래 화면을 닫았다');
});
testWidgets('[취소] 도 그만둔 것, 일이 끝나 close() 하면 창만 닫히고 그만둔 것이 아니다', (tester) async {
final page = await pushPage(tester);
final a = showProgressDialog(page, '바꾸는 중');
await tester.pump();
await tester.tap(find.text('취소'));
await tester.pumpAndSettle();
expect(a.cancelled(), isTrue);
expect(find.text('아래 화면'), findsOneWidget);
final b = showProgressDialog(page, '바꾸는 중');
await tester.pump();
b.close();
await tester.pumpAndSettle();
await b.done;
expect(b.cancelled(), isFalse);
expect(find.text('바꾸는 중'), findsNothing);
expect(find.text('아래 화면'), findsOneWidget);
});
}

View File

@ -56,7 +56,6 @@ void main() {
'-a --exclude-from=C:/lists/l.txt',
'-a --include-from=/cygdrive/c/l.txt',
r'-a --files-from=\\nas\share\l.txt',
'-a --exclude-from /sdcard/l.txt',
'-a --filter=". /cygdrive/c/rules.txt"',
r'-a -f". C:\r.txt"',
'-a --filter=": .rsync-filter"',
@ -75,6 +74,50 @@ void main() {
}
});
test('관리자 지시 (10/20): Windows 는 드라이브 · UNC · /cygdrive/ 만 전체 경로 ("/" 는 MSYS 가 자기 폴더 기준으로 읽음), Android 는 / · 끝 문구', () {
expect(unreadableOptions('-a --exclude-from=/lists/l.txt', windows: true), hasLength(1));
expect(unreadableOptions('-a --exclude-from=/sdcard/l.txt', windows: false), isEmpty);
expect(unreadableOptions('-a --exclude-from=C:/l.txt', windows: false), hasLength(1), reason: 'Android 에서는 / 로 시작해야');
for (final o in [r'-a --exclude-from=\\nas\s\l.txt', '-a --exclude-from=//nas/s/l.txt', '-a --exclude-from=/cygdrive/c/l.txt']) {
expect(unreadableOptions(o, windows: true), isEmpty, reason: o);
}
expect(optionProblemHint(optionProblems('-a --exclude-from=l.txt', windows: true), windows: true), r'C:\ 처럼 드라이브로 시작하는 전체 경로로');
expect(optionProblemHint(optionProblems('-a --exclude-from=l.txt', windows: false), windows: false), '/ 로 시작하는 전체 경로로');
});
test('관리자 판단 (10/20, 28 확인): 목록 · 기록 · 배치 파일의 C:\\ · C:/ · UNC 는 rsync 가 읽는 꼴로 바꿔 넘긴다 (막지 않음)', () {
// UNC 는 \\서버\… 로 (28 · 15 확인 10/20: 목록 · 기록 파일은 //서버/… 를 열지 못함, exit 11)
expect(
rsyncFileOptionArgs(
[r'--exclude-from=C:\l\a.txt', '--include-from', 'C:/l/b.txt', r'--files-from=\\nas\s\f.txt', '--exclude-from=//nas/s/g.txt'],
windows: true),
['--exclude-from=/cygdrive/c/l/a.txt', '--include-from', '/cygdrive/c/l/b.txt', r'--files-from=\\nas\s\f.txt', r'--exclude-from=\\nas\s\g.txt']);
// 받는 쪽은 UNC 를 \\ 로 (//서버 는 change_dir 실패, exit 3) · 원본은 // 그대로 (toCygwinPath)
expect(rsyncDestPath(r'\\nas\s\d'), r'\\nas\s\d');
expect(rsyncDestPath('//nas/s/d'), r'\\nas\s\d');
expect(rsyncDestPath(r'C:\d'), '/cygdrive/c/d');
expect(rsyncDestPath(r'\\nas\s\d', windows: false), r'\\nas\s\d');
expect(toCygwinPath(r'\\nas\s\src'), '//nas/s/src');
final a = rsyncArgs(options: '-a', sources: [r'\\nas\s\src'], dest: r'\\nas\s\dst', contents: true);
expect(a.sublist(a.length - 2), ['//nas/s/src/', r'\\nas\s\dst/']);
expect(rsyncFileOptionArgs(['--filter=merge,- C:/r/m.txt', '--filter', '. C:/r/d.txt', '-f. C:/r/e.txt', '-avf', 'merge C:/r/g.txt'], windows: true),
['--filter=merge,- /cygdrive/c/r/m.txt', '--filter', '. /cygdrive/c/r/d.txt', '-f. /cygdrive/c/r/e.txt', '-avf', 'merge /cygdrive/c/r/g.txt']);
expect(rsyncFileOptionArgs(['--log-file=C:/logs/r.log', '--write-batch', r'D:\b\batch', '--only-write-batch=C:/b2', '--read-batch=C:/b3'], windows: true),
['--log-file=/cygdrive/c/logs/r.log', '--write-batch', '/cygdrive/d/b/batch', '--only-write-batch=/cygdrive/c/b2', '--read-batch=/cygdrive/c/b3']);
// 바꾸지 않는 것: 패턴 · 폴더마다 읽는 규칙 · 받는 쪽 기준 옵션 · 다른 값 받는 글자 · 이미 /cygdrive · Android
final keep = ['--exclude=C:/x', '--filter=: .rsync-filter', '--filter=- C:/x', '--backup-dir=C:/b', '-e', 'C:/ssh.exe', '--exclude-from=/cygdrive/c/l.txt'];
expect(rsyncFileOptionArgs(keep, windows: true), keep);
expect(rsyncFileOptionArgs([r'--exclude-from=C:\l.txt'], windows: false), [r'--exclude-from=C:\l.txt']);
});
test('관리자 지시 (10/20, 28 지적): 표준 입력 (-) 은 따로 된 문구 (하이픈이 겹치지 않게)', () {
for (final o in ['-a --files-from=-', '-a --exclude-from=-', '-a --include-from -']) {
final bad = optionProblems(o, windows: true);
expect(bad.map((x) => x.text), ['표준 입력 (-) 은 쓸 수 없습니다'], reason: o);
expect(optionProblemHint(bad, windows: true), '목록 파일의 전체 경로를 적어 주세요', reason: o);
}
});
group('실제 rsync', () {
final rsync = sceneRsync;
late Directory tmp;
@ -134,6 +177,65 @@ void main() {
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
}
test('28 확인 (10/20): 목록 · 기록 파일을 C:/ · C:\\ 로 적어도 rsync 가 연다 (예전: failed to open exclude file, exit 11) - keep.txt 를 지킴', () async {
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
final lists = Directory(p.join(tmp.path, 'lists'))..createSync();
File(p.join(lists.path, 'ex.txt')).writeAsStringSync('keep.txt\n');
File(p.join(lists.path, 'rules.txt')).writeAsStringSync('- keep2.txt\n');
final slash = lists.path.replaceAll(r'\', '/');
for (final (name, opts) in [
('--exclude-from=C:/…', '-a --delete --exclude-from=$slash/ex.txt --exclude=keep2.txt'),
(r'--exclude-from=C:\…', '-a --delete --exclude-from="${lists.path}\\ex.txt" --exclude=keep2.txt'),
('--exclude-from C:/… (다음 낱말)', '-a --delete --exclude-from $slash/ex.txt --exclude=keep2.txt'),
('--filter="merge C:/…"', '-a --delete --exclude=keep.txt --filter="merge $slash/rules.txt"'),
('-f". C:/…"', '-a --delete --exclude=keep.txt -f". $slash/rules.txt"'),
]) {
final (s, d) = scene('s_$name'.replaceAll(RegExp(r'[^\w]'), '_'), 'd_$name'.replaceAll(RegExp(r'[^\w]'), '_'), ['a.txt'],
['keep.txt', 'keep2.txt', 'old.txt']);
expect(unreadableOptions(opts), isEmpty, reason: '$name: 막지 않음');
final job = TransferJob(sources: [s], dest: d, move: false, method: CopyMethod.rsync, options: opts, rsyncExe: rsync, contents: true);
await job.run();
expect(job.error, isNull, reason: '$name: ${job.error}');
expect(treeOf(d).keys.toSet(), {'keep.txt', 'keep2.txt', 'a.txt'}, reason: '$name: 목록의 것을 지키고 old.txt 만 지움');
}
// --log-file=C:/… 은 그 자리에 기록을 남긴다 (예전에는 열지 못해 실패)
final (s, d) = scene('s_log', 'd_log', ['a.txt'], []);
final job = TransferJob(
sources: [s], dest: d, move: false, method: CopyMethod.rsync, options: '-a --log-file=$slash/r.log', rsyncExe: rsync, contents: true);
await job.run();
expect(job.error, isNull, reason: '${job.error}');
expect(File(p.join(lists.path, 'r.log')).existsSync(), isTrue);
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
test('28 · 15 확인 (10/20): 네트워크 공유 (UNC, 관리 공유 \\\\localhost\\C\$ 로) - 원본 · 받는 쪽 · 목록 파일 (\\\\ · //) 이 모두 된다', () async {
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
String unc(String local) => '\\\\localhost\\C\$${local.substring(2)}';
if (!Directory(unc(tmp.path)).existsSync()) return markTestSkipped('관리 공유 \\\\localhost\\C\$ 를 쓸 수 없음');
final list = File(p.join(tmp.path, 'ex.txt'))..writeAsStringSync('keep.txt\n');
for (final (name, listArg) in [('목록 \\\\', unc(list.path)), ('목록 //', unc(list.path).replaceAll(r'\', '/'))]) {
final (s, d) = scene('us_${name.length}', 'ud_${name.length}', ['a.txt'], ['keep.txt', 'old.txt']);
// 사용자가 고른 그대로의 UNC 경로 (원본 · 받는 쪽 모두)
final job = TransferJob(
sources: [unc(s)], dest: unc(d), move: false, method: CopyMethod.rsync, options: '-a --delete --exclude-from=$listArg', rsyncExe: rsync, contents: true);
await job.run();
expect(job.error, isNull, reason: '$name: ${job.error}');
expect(treeOf(d).keys.toSet(), {'keep.txt', 'a.txt'}, reason: name);
}
// 실시간 동기화 쌍의 받는 쪽도 UNC
final (s, d) = scene('ls', 'ld', ['a.txt'], ['old.txt']);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..rsyncSource = 'custom'
..rsyncPath = rsync
..rsyncOptions = '-a'
..liveSyncPairs = [LiveSyncPair(unc(s), unc(d), method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-a')];
final live = LiveSync(c);
addTearDown(live.dispose);
await live.syncNow(c.settings.liveSyncPairs.single);
expect(live.status[LiveSync.keyOf(c.settings.liveSyncPairs.single)]!.$2, isNot(contains('실패')));
expect(treeOf(d).keys.toSet(), {'a.txt'});
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
test("실시간 동기화 (LiveSync rsync 쌍): ' 두 개의 --exclude · ' 가 든 경로", () async {
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
final (s, d) = scene("s'1", "d'2", ['a.txt'], ["it's.txt", "Bob's.txt", 'old.txt']);

View File

@ -16,6 +16,9 @@ import 'support/sftp_server.dart';
/// sftp_next: SFTP 동영상을 받지 않고 이 기기 안의 다리 (127.0.0.1) 로 스트리밍 - 같은 폴더의 다음 동영상 · 그 자막.
/// 실제 서버 시험은 tool/sftp_test_server.py (paramiko) - 파이썬이 없으면 건너뛴다 (sftp_test 와 같음)
/// 시험 서버 비밀번호 - 무작위 주소에 우연히 들어가지 않게 길고 드물게 (관리자 지시 10/20, 28 이 찾음)
const sftpPw = 'sftp-Pw-3Lm8vZ2w';
void main() {
final hasServer = sftpTestServerAvailable;
late Directory tmp;
@ -26,10 +29,10 @@ void main() {
if (!hasServer) return;
tmp = Directory.systemTemp.createTempSync('jj_sftpnext_');
root = Directory(p.join(tmp.path, 'root'))..createSync();
final s = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'hostkey'));
final s = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'hostkey'), password: sftpPw);
server = s;
SftpRegistry.configure([
SftpServer(id: 's1', name: 'test', host: '127.0.0.1', port: s.port, user: 'jj', password: 'pw', hostKeyFingerprint: s.fingerprint),
SftpServer(id: 's1', name: 'test', host: '127.0.0.1', port: s.port, user: 'jj', password: sftpPw, hostKeyFingerprint: s.fingerprint),
]);
Directory(p.join(root.path, '영상')).createSync();
File(p.join(root.path, '영상', '1화.mkv')).writeAsStringSync('0123456789');
@ -93,7 +96,10 @@ void main() {
expect(u1, endsWith('/${Uri.encodeComponent('1화.mkv')}'), reason: 'mpv 가 확장자로 형식을 알게');
expect(u1, isNot(equals(u2)));
expect(bridge.url('sftp://s1/영상/1화.mkv'), u1, reason: '같은 경로는 같은 주소');
expect(u1, isNot(contains('pw')), reason: '주소에 비밀번호 없음');
// 관리자 지시 (10/20, 28 이 찾음): 시험 비밀번호가 두 글자 "pw" 라 다리 주소의 무작위 표 (예 …-4pwvUR-…) 에
// 우연히 들어가 "주소에 비밀번호 없음" 이 가끔 실패했다 (비밀번호가 샌 것이 아님) - 길고 드문 비밀번호로 보고, 주소의 사용자 정보도 따로
expect(u1, isNot(contains(sftpPw)), reason: '주소에 비밀번호 없음');
expect(Uri.parse(u1).userInfo, isEmpty, reason: '주소에 사용자 정보 없음');
var (code, h, body) = await get(u1);
expect((code, body), (200, '0123456789'));

View File

@ -124,7 +124,8 @@ void main() {
SmbRegistry.configure(const []);
});
const server = SmbServer(id: 'm1', name: 'NAS', host: 'nas', share: 'video', user: 'jj', password: 'pw');
// 길고 드문 비밀번호 - 다리 주소의 무작위 표에 우연히 들어가지 않게 (관리자 지시 10/20, 28 이 찾음)
const server = SmbServer(id: 'm1', name: 'NAS', host: 'nas', share: 'video', user: 'jj', password: 'smb-Pw-5Qh9kT4r');
test('smb:// 로 폴더 · 쓰기 (1MB 조각) · 읽기 · 목록 · 이름 바꾸기 (덮어쓰기) · 지우기 · 화면 이름', () async {
SmbRegistry.configure([server]);
@ -196,10 +197,10 @@ void main() {
final secrets = MemorySecretStore();
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
await store.save(AppSettings()..smbServers = [server]);
expect(File(p.join(tmp.path, 'settings.json')).readAsStringSync(), isNot(contains('"pw"')));
expect(secrets.values['smb.pw.m1'], 'pw');
expect(File(p.join(tmp.path, 'settings.json')).readAsStringSync(), isNot(contains('smb-Pw-5Qh9kT4r')));
expect(secrets.values['smb.pw.m1'], 'smb-Pw-5Qh9kT4r');
final back = (await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load()).smbServers.single;
expect((back.host, back.share, back.user, back.password), ('nas', 'video', 'jj', 'pw'));
expect((back.host, back.share, back.user, back.password), ('nas', 'video', 'jj', 'smb-Pw-5Qh9kT4r'));
});
test('경로 입력의 smb:// · \\\\서버\\공유 → 서버 추가 창에 채울 (서버, 공유, 폴더)', () {
@ -329,7 +330,10 @@ void main() {
final bridge = await RemoteStreamBridge.get();
final u1 = bridge.url('smb://m1/영상/1화.mkv'), u2 = bridge.url('smb://m1/영상/2화.mkv');
expect(u1, startsWith('http://127.0.0.1:'));
expect(u1, isNot(contains('pw')), reason: '주소에 비밀번호 없음');
// 관리자 지시 (10/20, 28 이 찾음): 시험 비밀번호가 두 글자 "pw" 라 다리 주소의 무작위 표 (예 …-4pwvUR-…) 에
// 우연히 들어가 "주소에 비밀번호 없음" 이 가끔 실패했다 (비밀번호가 샌 것이 아님) - 길고 드문 비밀번호로 보고, 주소의 사용자 정보도 따로
expect(u1, isNot(contains(server.password)), reason: '주소에 비밀번호 없음');
expect(Uri.parse(u1).userInfo, isEmpty, reason: '주소에 사용자 정보 없음');
Future<(int, String)> get(String url, [String? range]) async {
final http = HttpClient();
try {

View File

@ -1,99 +0,0 @@
#!/bin/sh
# 관리자 지시 (10/20): Android (arm64) 에서 HWP · HWPX 를 PDF 로 바꿔 앱의 보기 화면으로 연다 - rhwp (Rust, MIT) 를
# tool/rhwp_ffi (얇은 C ABI) 와 함께 NDK 로 빌드해 third_party/rhwp/android/arm64-v8a/libjj_rhwp.so 로 둔다 (rsync · 7zz 와 같은 방식 -
# APK 의 jniLibs 로 들어가 앱이 dart:ffi 로 읽는다).
# 소스: https://github.com/edwardkim/rhwp v0.8.7 = 커밋 1a76570 (고정 - git 커밋 해시가 내용의 SHA-1 지문이다, 고치지 않음).
# 저장소가 크므로 (예제 문서 · PDF 로 4GB 넘음) 빌드에 쓰는 폴더만 받는다 (git sparse, 깊이 1).
# 라이선스: rhwp MIT (LICENSE → third_party/rhwp) · .so 에 실제로 링크되는 crate 마다 이름 · 판 · 라이선스 · 원문은
# tool/licenses/rhwp_crates.py 가 assets/licenses/RHWP_LICENSES.txt 로 만든다 (관리자 지시 10/20 - 저장소의 THIRD_PARTY_LICENSES.md 는
# npm 쪽이 섞여 있고 링크되는 crate 가 빠져 쓰지 않는다). Cargo.lock 이 바뀌면 test/licenses_test.dart 가 다시 만들라고 잡는다.
# 도구: Rust 1.93.1 (rhwp 의 rust-toolchain.toml 과 같음, tools/rust - RUSTUP_HOME · CARGO_HOME) + Android NDK clang (링커).
# Windows 에서는 호스트 쪽 (빌드 스크립트) 을 MSVC 로 링크하므로 tools/VSBuildTools 의 vcvars64 환경을 읽어 온다.
# 16KB 쪽 기기 (Android 15+) 에서도 읽히게 -z max-page-size=16384, 다시 만들어도 같은 파일이 되게 경로를 /jj 로 바꿔 적는다.
# 의존 crate 판은 tool/rhwp_ffi/Cargo.lock 으로 고정 (--locked).
# 사용법 (Git Bash): sh tool/build_rhwp_android.sh - Android NDK 가 필요 (ANDROID_NDK 또는 tools/android-sdk/ndk/*)
# 다시 만들어지는지 보기: JJ_RHWP_CHECK=1 sh tool/build_rhwp_android.sh (깨끗한 target 으로 두 번 빌드해 해시를 견줌)
set -e
APP=$(cd "$(dirname "$0")/.." && pwd)
TOOLS=$(cd "$APP/.." && pwd)/tools
VER=0.8.7
REV=1a76570e833917d15817415a53c09ad61ab3203f
NDK_ROOT=${ANDROID_NDK:-$(ls -d "$TOOLS"/android-sdk/ndk/* | sort | tail -1)}
case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) HOST=windows-x86_64; EXE=.exe; CMD=.cmd ;; Darwin) HOST=darwin-x86_64; EXE=; CMD= ;; *) HOST=linux-x86_64; EXE=; CMD= ;; esac
TC=$NDK_ROOT/toolchains/llvm/prebuilt/$HOST/bin
export RUSTUP_HOME=${RUSTUP_HOME:-$TOOLS/rust/rustup}
export CARGO_HOME=${CARGO_HOME:-$TOOLS/rust/cargo}
export PATH="$CARGO_HOME/bin:$PATH"
# Windows: 호스트 링커 (MSVC) 환경
if [ "$HOST" = windows-x86_64 ] && [ -z "$VCINSTALLDIR" ]; then
VC=$(cygpath -w "$TOOLS/VSBuildTools/VC/Auxiliary/Build/vcvars64.bat")
ENVF=$(mktemp)
# cmd 에 따옴표를 넘기면 Git Bash 가 바꿔 버려, 부를 것을 .cmd 파일로 적어 그것을 돌린다
BAT=$(mktemp --suffix=.cmd)
printf '@call "%s" >nul\r\n@set\r\n' "$VC" > "$BAT"
cmd //c "$(cygpath -w "$BAT")" | tr -d '\r' > "$ENVF"
rm -f "$BAT"
while IFS='=' read -r k v; do
case "$k" in
Path|PATH) PATH="$(cygpath -u -p "$v"):$PATH" ;;
LIB|LIBPATH|INCLUDE|VCINSTALLDIR|VCToolsInstallDir|WindowsSdkDir|WindowsSDKVersion|UCRTVersion|UniversalCRTSdkDir) export "$k=$v" ;;
esac
done < "$ENVF"
rm -f "$ENVF"
export PATH
[ -n "$VCINSTALLDIR" ] || { echo "vcvars64 환경을 읽지 못함: $VC"; exit 1; }
fi
# 작업 폴더: tools 아래 (빌드 캐시가 수 GB - 시스템 임시 폴더 (C:) 가 모자라 시험이 실패했다, 10/20). 다시 빌드할 때 이어 쓴다
WORK=${JJ_RHWP_WORK:-$TOOLS/src/rhwp/build}
mkdir -p "$WORK"
SRC=$WORK/rhwp
if [ "$(git -C "$SRC" rev-parse HEAD 2>/dev/null)" != "$REV" ]; then
rm -rf "$SRC"
git init -q "$SRC"
git -C "$SRC" remote add origin https://github.com/edwardkim/rhwp.git
git -C "$SRC" sparse-checkout init --no-cone
printf '%s\n' '/*' '!/samples/' '!/pdf/' '!/mydocs/' '!/saved/' '!/tests/' '!/gym/' '!/rhwp-*/' '!/npm/' '!/typescript/' \
'!/tools/' '!/fuzz/' '!/scripts/' '/saved/blank2010.hwp' '/mydocs/manual/' '/mydocs/tech/' > "$SRC/.git/info/sparse-checkout"
git -C "$SRC" fetch -q --depth 1 --filter=blob:none origin "$REV"
git -C "$SRC" checkout -q FETCH_HEAD
fi
[ "$(git -C "$SRC" rev-parse HEAD)" = "$REV" ] || { echo "rhwp 커밋이 다름"; exit 1; }
[ -z "$(git -C "$SRC" status --porcelain)" ] || { echo "rhwp 소스가 바뀌어 있음"; exit 1; }
rm -rf "$WORK/ffi"
cp -r "$APP/tool/rhwp_ffi" "$WORK/ffi"
# 처음 한 번: 의존 판을 rhwp 의 Cargo.lock 그대로 이어받아 tool/rhwp_ffi/Cargo.lock 을 만든다 (그 뒤로는 --locked)
LOCKED=--locked
if [ ! -f "$APP/tool/rhwp_ffi/Cargo.lock" ]; then
cp "$SRC/Cargo.lock" "$WORK/ffi/Cargo.lock"
LOCKED=
fi
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TC/aarch64-linux-android24-clang$CMD"
export CC_aarch64_linux_android="$TC/aarch64-linux-android24-clang$CMD"
export AR_aarch64_linux_android="$TC/llvm-ar$EXE"
W=$WORK
[ "$HOST" = windows-x86_64 ] && W=$(cygpath -m "$WORK")
export RUSTFLAGS="-C link-arg=-Wl,-z,max-page-size=16384 -C link-arg=-Wl,--build-id=none --remap-path-prefix=$W=/jj --remap-path-prefix=$(cygpath -m "$CARGO_HOME" 2>/dev/null || echo "$CARGO_HOME")=/cargo"
export JJ_RHWP_VERSION=$VER
build() {
cargo build -q --release $LOCKED --target aarch64-linux-android --manifest-path "$WORK/ffi/Cargo.toml" --target-dir "$1"
"$TC/llvm-strip$EXE" -o "$2" "$1/aarch64-linux-android/release/libjj_rhwp.so"
}
OUT="$APP/third_party/rhwp/android/arm64-v8a"
mkdir -p "$OUT"
build "$WORK/target" "$OUT/libjj_rhwp.so"
[ -n "$LOCKED" ] || cp "$WORK/ffi/Cargo.lock" "$APP/tool/rhwp_ffi/Cargo.lock"
cp "$SRC/LICENSE" "$OUT/../LICENSE.txt"
rm -f "$OUT/../THIRD_PARTY_LICENSES.md"
# 링크되는 crate 의 라이선스 · 원문 (cargo tree · metadata - 의존은 위 빌드가 받아 둠)
python "$APP/tool/licenses/rhwp_crates.py" "$WORK/ffi/Cargo.toml" "$SRC" "$VER" "$REV" "$APP/assets/licenses/RHWP_LICENSES.txt"
if [ -n "$JJ_RHWP_CHECK" ]; then
rm -rf "$WORK/target2"
build "$WORK/target2" "$WORK/again.so"
A=$(sha256sum < "$OUT/libjj_rhwp.so"); B=$(sha256sum < "$WORK/again.so")
[ "$A" = "$B" ] && echo "다시 만들어도 같음" || { echo "다시 만들면 다름: $A / $B"; exit 1; }
fi
ls -l "$OUT/libjj_rhwp.so"
sha256sum "$OUT/libjj_rhwp.so"

View File

@ -8,8 +8,10 @@
# 새 버전 확인도 "최신과 같음" 이 된다. 시험판은 flutter build apk --build-number 9xx 로 빌드한다. 정식 판 APK 는 -Release.
# - 순번 900 이상의 v 태그 (시험판 Pre-release, 예: v2026.10.18_977) 는 정식 판으로 보지 않는다 (관리자 지시 10/19, 28 지적:
# 시험판에 태그를 단 뒤 같은 APK 를 다시 검사하면 "정식 판 번호와 같음" 으로 거짓 실패). 정식 판 (-Release) 인데 순번이 900 이상이면 실패.
# - HWP 보기 (관리자 지시 10/20, 28 지적): APK 안 libjj_rhwp.so 가 앱의 Dart 와 같은 C ABI 인지 - 내보낸 기호 (NDK llvm-nm) 에
# jj_rhwp_cancel · jj_rhwp_abi_version 등이 모두 있어야 한다 (옛 .so 는 이름 · 목록만으로는 못 가린다).
# - versionCode 의 모양 (관리자 지시 10/20, 15 제안): versionCode = 판 이름 날짜 (2026.10.18 → 261018) × 1000 + 순번 (000~999).
# 987 을 --build-number 261018987 로 빌드해 versionCode 522036987 (날짜가 두 번 더해짐) 이 나왔는데 통과했다 - 사용자 기기에 깔리면
# 그 뒤의 정식 판 (261018xxx) 이 "낮은 버전" 이라 업데이트가 막힌다. build.gradle.kts 가 날짜를 붙이므로 APK 의 --build-number 는 순번만 (예 987).
# 정식 판 (-Release) 은 순번 < 900, 시험판은 순번 >= 900.
# 사용: powershell -ExecutionPolicy Bypass -File tool\check_apk.ps1 -Apk build\app\outputs\flutter-apk\app-release.apk [-Release]
param(
[Parameter(Mandatory = $true)][string]$Apk,
@ -43,7 +45,7 @@ if (-not $Libs) { $Libs = Join-Path $app 'android\app\third_party_libs.txt' }
$listed = @(Get-Content $Libs | ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') })
# third_party 에 있는 파일 (목록 밖이면 APK 에 없어야 함)
$tp = @()
foreach ($d in 'third_party\rsync\android', 'third_party\sdcpp\android', 'third_party\tor\android', 'third_party\7zip\android', 'third_party\rhwp\android') {
foreach ($d in 'third_party\rsync\android', 'third_party\sdcpp\android', 'third_party\tor\android', 'third_party\7zip\android') {
$dir = Join-Path $app $d
if (Test-Path $dir) { $tp += @(Get-ChildItem $dir -Recurse -Filter *.so | ForEach-Object { $_.Name }) }
}
@ -52,13 +54,6 @@ try {
$libsIn = @($zip.Entries | Where-Object { $_.FullName -like 'lib/arm64-v8a/*.so' } | ForEach-Object { $_.Name })
$dexes = @($zip.Entries | Where-Object { $_.FullName -match '^classes\d*\.dex$' })
if ($dexes.Count -eq 0) { throw "dex 가 없습니다: $Apk" }
# HWP 보기 라이브러리는 꺼내 두고 아래에서 기호를 본다
$rhwpSo = $null
$rhwpEntry = $zip.Entries | Where-Object { $_.FullName -eq 'lib/arm64-v8a/libjj_rhwp.so' } | Select-Object -First 1
if ($rhwpEntry) {
$rhwpSo = Join-Path ([System.IO.Path]::GetTempPath()) ("jj_check_rhwp_{0}.so" -f [guid]::NewGuid().ToString('N'))
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($rhwpEntry, $rhwpSo)
}
$found = $null
foreach ($e in $dexes) {
$s = $e.Open()
@ -70,21 +65,6 @@ try {
$zip.Dispose()
}
$bad = @()
# HWP 보기: libjj_rhwp.so 의 내보낸 기호 (lib/platform/android/hwp_pdf_ffi.dart 의 requiredSymbols 와 같게)
$rhwpNote = ''
if ($rhwpSo) {
try {
$nm = Get-ChildItem (Join-Path $app '..\tools\android-sdk\ndk') -Recurse -Filter llvm-nm.exe -ErrorAction SilentlyContinue | Select-Object -Last 1
if (-not $nm) { throw 'llvm-nm.exe 를 찾지 못했습니다 (tools\android-sdk\ndk)' }
$syms = @(& $nm.FullName -D --defined-only $rhwpSo 2>$null | ForEach-Object { ($_ -split '\s+')[-1] })
$need = 'jj_rhwp_abi_version', 'jj_rhwp_open', 'jj_rhwp_page_count', 'jj_rhwp_export_pdf', 'jj_rhwp_cancel', 'jj_rhwp_cancel_all', 'jj_rhwp_close', 'jj_rhwp_free'
$miss = @($need | Where-Object { $syms -notcontains $_ })
if ($miss) { $bad += "libjj_rhwp.so 가 옛 C ABI 입니다 - 없는 함수: $($miss -join ', ') (sh tool/build_rhwp_android.sh 로 다시 빌드)" }
else { $rhwpNote = " · libjj_rhwp.so 함수 $($need.Count)개 있음" }
} finally {
Remove-Item $rhwpSo -Force -ErrorAction SilentlyContinue
}
}
# 시험판 번호: aapt2 로 versionCode · versionName 을 읽어 정식 판 태그의 번호와 견준다 (build.gradle.kts 와 같은 셈: 년%100 · 월 · 일 · 번호)
$aapt = Get-ChildItem (Join-Path $app '..\tools\android-sdk\build-tools') -Recurse -Filter aapt2.exe -ErrorAction SilentlyContinue | Select-Object -Last 1
if (-not $aapt -and $env:ANDROID_HOME) { $aapt = Get-ChildItem (Join-Path $env:ANDROID_HOME 'build-tools') -Recurse -Filter aapt2.exe -ErrorAction SilentlyContinue | Select-Object -Last 1 }
@ -98,6 +78,17 @@ $same = @(foreach ($t in $tags) {
if ($t -match '^v(\d+)\.(\d+)\.(\d+)_(\d+)$' -and [long]$Matches[4] -lt 900 -and
([long]$Matches[1] % 100) * 10000000 + [long]$Matches[2] * 100000 + [long]$Matches[3] * 1000 + [long]$Matches[4] -eq $code) { $t }
})
# versionCode 의 모양: 판 이름의 날짜 × 1000 + 순번 (build.gradle.kts 와 같은 셈)
if ($vname -match '^(\d+)\.(\d+)\.(\d+)') {
$day = ([long]$Matches[1] % 100) * 10000 + [long]$Matches[2] * 100 + [long]$Matches[3]
if ([math]::Floor($code / 1000) -ne $day) {
$bad += "versionCode $code 가 판 이름 $vname 의 날짜 ($day × 1000 + 순번) 와 맞지 않음 - APK 는 --build-number 에 순번만 (예 987, 날짜를 붙이지 않음)"
} elseif (-not $Release -and ($code % 1000) -lt 900) {
$bad += "시험판인데 versionCode $code ($vname) 의 순번이 $($code % 1000) - 시험판은 --build-number 9xx (정식 판이면 -Release)"
}
} else {
$bad += "판 이름을 읽지 못함: '$vname' (년.월.일 이어야 함)"
}
if ($Release -and ($code % 1000) -ge 900) {
$bad += "정식 판 (-Release) 인데 versionCode $code ($vname) 의 순번이 $($code % 1000) - 시험판 번호입니다 (정식 판은 --build-number 없이, pubspec 의 +순번)"
}
@ -114,4 +105,4 @@ if (-not $found) {
Write-Host "실패: GeneratedPluginRegistrant 가 dex 에 없습니다 (켜자마자 꺼지는 APK). flutter pub get 뒤 --no-pub 없이 다시 빌드하세요." -ForegroundColor Red
exit 1
}
Write-Host "통과: GeneratedPluginRegistrant 있음 ($found) · third_party 실행 파일 $($listed.Count)개 목록과 같음$rhwpNote · versionCode $code ($vname)$(if ($Release) { ' (정식 판)' } else { ' (시험판 - 정식 판 번호와 다름)' })"
Write-Host "통과: GeneratedPluginRegistrant 있음 ($found) · third_party 실행 파일 $($listed.Count)개 목록과 같음 · versionCode $code ($vname)$(if ($Release) { ' (정식 판)' } else { ' (시험판 - 정식 판 번호와 다름)' })"

View File

@ -5,6 +5,10 @@
# - 정식 판 (-Release): 번호가 1000 보다 작아야 한다 (시험 번호가 정식 zip 에 들어가지 않게).
# - 정식 판 (-Release) 의 순번이 900 이상이면 실패 (900 이상은 시험판 순번 - check_apk.ps1 과 같은 규칙, 관리자 지시 10/19).
# 이 검사는 git 태그를 보지 않으므로 시험판 태그를 단 뒤 다시 검사해도 거짓 실패가 없다.
# - 시험판 번호의 날짜는 판 이름 (pubspec) 의 날짜 (관리자 지시 10/20, d3 판단): 2026.10.18+261018987 은 맞고 +261020985 (빌드한 날) 는 실패.
# 근거: ① APK versionCode (판 이름 날짜 × 1000 + 순번) 와 같은 번호가 되어 두 판을 같은 이름으로 견줄 수 있다.
# ② 앱의 판 견주기 (compareVersions) 는 + 뒤 번호 전체를 견준다 - 날짜가 섞이면 같은 판 이름에서 985 (261020985) 가
# 987 (261018987) 보다 새것으로 보인다. 순번은 900 이상 (시험판).
# 사용: powershell -ExecutionPolicy Bypass -File tool\check_windows_build.ps1 -Dir <배포 폴더 (Lib 가 있는 곳)> [-Release]
param(
[Parameter(Mandatory = $true)][string]$Dir,
@ -21,6 +25,12 @@ if ($Release) {
if ($build -ge 900) { throw "정식 판인데 시험 번호입니다: $ver (화면 v$shown). --build-number 없이 (pubspec 의 +순번) 빌드하세요" }
} elseif ($build -lt 1000) {
throw ("시험판인데 정식 판 번호입니다: $ver (화면 v$shown - 정식 판과 같게 보임).`n" +
"flutter build windows --release --build-number <년월일><순번 3자리> (예: 261012972) 로 다시 빌드하세요")
"flutter build windows --release --build-number <판 이름 날짜 6자리><순번 3자리> (예: 261012972) 로 다시 빌드하세요")
} else {
$day = ([long]$Matches[1] % 100) * 10000 + [long]$Matches[2] * 100 + [long]$Matches[3]
if ([math]::Floor($build / 1000) -ne $day -or ($build % 1000) -lt 900) {
throw ("시험판 번호가 판 이름과 맞지 않습니다: $ver - 번호는 판 이름의 날짜 ($day) + 순번 900~999 여야 합니다 (빌드한 날이 아님).`n" +
"flutter build windows --release --build-number $day<순번 3자리> (예: ${day}987) 로 다시 빌드하세요")
}
}
"통과: $ver (화면 v$shown) · $(if ($Release) { '정식 판' } else { '시험판 - 정식 판 번호와 다름' })"

View File

@ -1,140 +0,0 @@
"""관리자 지시 (10/20, 28 지적): libjj_rhwp.so 에 실제로 링크되는 Rust crate 마다 이름 · 판 · 라이선스와 원문 · 저작권 고지를 모아
assets/licenses/RHWP_LICENSES.txt 를 만든다 (tool/build_rhwp_android.sh 가 빌드 뒤에 부른다).
- 링크되는 것: cargo tree --target aarch64-linux-android -e normal,no-proc-macro (빌드 전용 · 개발 · proc-macro 는 .so 에 들어가지 않음)
- 원문: crate 폴더 맨 위의 LICENSE* · LICENCE* · COPYING* · NOTICE* · COPYRIGHT* · UNLICENSE* (cargo metadata 의 manifest_path)
원문 파일이 없는 crate 는 같은 라이선스의 원문을 맨 뒤 "라이선스 원문" 에 한 번씩 넣는다.
- Cargo.lock 의 나머지는 "링크되지 않음" 목록에 적는다 - test/licenses_test.dart 가 두 목록의 합이 Cargo.lock 과 같은지 본다
(Cargo.lock 이 바뀌었는데 이 파일을 다시 만들지 않으면 시험이 잡는다).
- rhwp 저장소의 THIRD_PARTY_LICENSES.md (npm 쪽 포함) 는 쓰지 않는다.
사용: python tool/licenses/rhwp_crates.py <ffi Cargo.toml> <rhwp 소스 폴더> <rhwp 판> <rhwp 커밋> <출력 파일>
(cargo 는 PATH · CARGO_HOME 의 것, 의존은 이미 받아 둔 것 - --offline --locked)
"""
import hashlib
import json
import os
import re
import subprocess
import sys
manifest, rhwp_src, version, commit, out = sys.argv[1:6]
TARGET = 'aarch64-linux-android'
def cargo(*args):
r = subprocess.run(['cargo', *args, '--manifest-path', manifest, '--locked', '--offline'],
capture_output=True, text=True, encoding='utf-8')
if r.returncode != 0:
sys.exit('cargo %s 실패:\n%s' % (args[0], r.stderr))
return r.stdout
# 1. 링크되는 crate (이름, 판)
linked = set()
for line in cargo('tree', '--target', TARGET, '-e', 'normal,no-proc-macro', '--prefix', 'none', '--format', '{p}').splitlines():
m = re.match(r'^(\S+) v(\S+)', line.strip())
if m:
linked.add((m.group(1), m.group(2)))
# 2. 모든 crate 의 폴더 · 라이선스
meta = json.loads(cargo('metadata', '--format-version', '1', '--filter-platform', TARGET))
pkgs = {(p['name'], p['version']): p for p in meta['packages']}
# 3. Cargo.lock 의 모든 crate
lock = open(os.path.join(os.path.dirname(manifest), 'Cargo.lock'), encoding='utf-8').read()
locked = set(re.findall(r'\[\[package\]\]\s*\nname = "([^"]+)"\s*\nversion = "([^"]+)"', lock))
missing = linked - locked
if missing:
sys.exit('Cargo.lock 에 없는 crate: %s' % sorted(missing))
ours = {'jj_rhwp'}
NAMES = re.compile(r'^(LICEN[CS]E|COPYING|NOTICE|COPYRIGHT|UNLICENSE)', re.I)
def license_files(pkg):
d = os.path.dirname(pkg['manifest_path'])
files = []
for n in sorted(os.listdir(d)):
p = os.path.join(d, n)
if os.path.isfile(p) and NAMES.match(n):
files.append((n, open(p, encoding='utf-8', errors='replace').read().strip()))
if pkg.get('license_file'):
p = os.path.join(d, pkg['license_file'])
if os.path.isfile(p) and os.path.basename(p) not in [f for f, _ in files]:
files.append((os.path.basename(p), open(p, encoding='utf-8', errors='replace').read().strip()))
return files
# 원문이 없는 crate 를 위해 라이선스 이름 → 원문 하나 (같은 이름의 원문 파일 하나만 가진 crate 에서)
SPDX_FILE = {'MIT': r'LICEN[CS]E-?MIT', 'Apache-2.0': r'LICEN[CS]E-?APACHE', 'BSD-3-Clause': r'LICEN[CS]E-?BSD',
'Zlib': r'LICEN[CS]E-?ZLIB', 'BSL-1.0': r'LICEN[CS]E-?BOOST', 'Unicode-3.0': r'LICEN[CS]E-?UNICODE'}
spdx_text = {}
sections = []
seen_text = {}
no_file = []
for key in sorted(linked, key=lambda k: (k[0].lower(), k[1])):
name, ver = key
pkg = pkgs[key]
lic = pkg.get('license') or ('see ' + pkg['license_file'] if pkg.get('license_file') else '?')
if name in ours:
continue
files = license_files(pkg)
for fname, text in files:
for spdx, pat in SPDX_FILE.items():
if re.fullmatch(pat + r'(\.(txt|md))?', fname, re.I) and spdx not in spdx_text:
spdx_text[spdx] = (text, '%s %s' % (name, ver))
body = []
for fname, text in files:
h = hashlib.sha256(text.encode()).hexdigest()
if h in seen_text:
body.append('[%s] (%s 의 %s 와 같은 글)' % (fname, *seen_text[h]))
else:
seen_text[h] = ('%s %s' % (name, ver), fname)
body.append('[%s]\n%s' % (fname, text))
if not files:
no_file.append((name, ver, lic))
body.append('(crate 에 원문 파일이 없음 - 아래 "라이선스 원문" 의 %s)' % lic)
authors = ', '.join(pkg.get('authors') or [])
sections.append('=== %s %s (%s) ===\n%s%s\n%s' % (
name, ver, lic, 'Authors: %s\n' % authors if authors else '', pkg.get('repository') or '', '\n\n'.join(body)))
not_linked = sorted(locked - linked, key=lambda k: (k[0].lower(), k[1]))
lines = [
'rhwp v%s (https://github.com/edwardkim/rhwp, commit %s) - MIT' % (version, commit),
'Android: libjj_rhwp.so (HWP / HWPX -> PDF), built by tool/build_rhwp_android.sh with tool/rhwp_ffi (MIT)',
'This file lists every Rust crate linked into libjj_rhwp.so (target %s, normal dependencies,' % TARGET,
'build-only / dev / proc-macro crates excluded) with its license and the license / notice files it ships.',
'',
'---------------------------------------------------------------------',
'rhwp LICENSE',
'---------------------------------------------------------------------',
open(os.path.join(rhwp_src, 'LICENSE'), encoding='utf-8').read().strip(),
'',
'---------------------------------------------------------------------',
'Linked crates (%d)' % len(linked - {(n, v) for n, v in linked if n in ours}),
'---------------------------------------------------------------------',
]
for name, ver in sorted(linked, key=lambda k: (k[0].lower(), k[1])):
if name not in ours:
lines.append('linked: %s %s (%s)' % (name, ver, pkgs[(name, ver)].get('license') or '?'))
lines += ['', '---------------------------------------------------------------------',
'Not linked into libjj_rhwp.so (build tools, proc-macros, other platforms) - listed for completeness',
'---------------------------------------------------------------------']
lines += ['not linked: %s %s' % k for k in not_linked]
lines += ['', '---------------------------------------------------------------------', 'License and notice files',
'---------------------------------------------------------------------', '']
lines += [s + '\n' for s in sections]
need = sorted({l for _, _, l in no_file})
if need:
lines += ['---------------------------------------------------------------------', '라이선스 원문 (원문 파일이 없는 crate 용)',
'---------------------------------------------------------------------', '']
for lic in need:
for spdx in re.findall(r'[A-Za-z0-9.\-]+', lic):
if spdx in spdx_text:
text, src = spdx_text.pop(spdx)
lines += ['=== %s (원문: %s 의 파일) ===' % (spdx, src), text, '']
text = '\n'.join(lines).rstrip() + '\n'
open(out, 'wb').write(text.replace('\n', '\r\n').encode('utf-8'))
print('linked %d · not linked %d · 원문 파일 없음 %d %s' % (len(linked) - 1, len(not_linked), len(no_file),
[f'{n} {v}' for n, v, _ in no_file]))

1928
tool/rhwp_ffi/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +0,0 @@
# JJ_MKVMaker: HWP · HWPX → PDF (rhwp) 의 C ABI - tool/build_rhwp_android.sh 가 rhwp 소스 (커밋 고정) 옆에 두고 빌드한다
[package]
name = "jj_rhwp"
version = "0.1.0"
edition = "2021"
license = "MIT"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
# 빌드 스크립트가 ../rhwp 에 rhwp v0.8.7 (커밋 1a76570) 을 받아 둔다
rhwp = { path = "../rhwp", default-features = false }
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = true

View File

@ -1,5 +0,0 @@
# rhwp v0.8.7 과 같은 판 (rhwp 의 rust-toolchain.toml)
[toolchain]
channel = "1.93.1"
targets = ["aarch64-linux-android"]
profile = "minimal"

View File

@ -1,269 +0,0 @@
//! JJ_MKVMaker: HWP · HWPX 를 PDF 로 바꾸는 얇은 C ABI (rhwp, MIT - https://github.com/edwardkim/rhwp).
//! 관리자 지시 (10/20): Android 에서 HWP · HWPX 를 앱의 보기 화면 (PDF) 으로 연다 - 문서를 한 번 열고, 쪽 범위마다 PDF 를 만든다
//! (큰 문서는 앞쪽부터 몇 쪽씩 보이면서 뒤를 이어 만든다). Dart 는 dart:ffi 로 부른다 (lib/platform/android/hwp_pdf_ffi.dart).
//! - 오류는 종류 코드 (JJ_RHWP_E_*) 로 돌려주고, 원문 (rhwp 의 글) 은 err 로 (화면은 코드로 고른 번역 글, 원문은 [자세히] · 작업 기록).
//! - jj_rhwp_cancel(요청 번호): 다른 스레드에서 그 요청만 멈춘다 (쪽마다 확인 - 관리자 지시 10/20, 시간 제한 뒤에도 계속 돌지 않게).
//! jj_rhwp_cancel_all: 닫을 때 - 도는 것 · 앞으로 올 것 모두 멈춘다 (ABI 3: 예전에는 문서 하나의 깃발이라 한 요청의 시간 제한이
//! 앞 묶음까지 멈췄고, 바꾸기를 시작할 때 깃발을 지워 닫기 요청이 사라졌다 - 28 지적).
//! 오류 글 · 돌려준 글은 jj_rhwp_free 로 푼다. 어느 함수도 panic 을 C 쪽으로 넘기지 않는다.
use std::ffi::{c_char, c_int, CStr, CString};
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::path::PathBuf;
use std::ptr::null_mut;
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use rhwp::document_core::DocumentCore;
use rhwp::paint::RenderProfile;
use rhwp::renderer::pdf::{svgs_to_pdf_with_options, PdfExportOptions};
/// 성공
pub const JJ_RHWP_OK: c_int = 0;
/// jj_rhwp_cancel 로 멈춤
pub const JJ_RHWP_E_CANCELLED: c_int = 1;
/// 파일을 읽지 못함
pub const JJ_RHWP_E_READ: c_int = 2;
/// HWP · HWPX 로 읽지 못함 (깨짐 · 지원하지 않는 형식)
pub const JJ_RHWP_E_INVALID: c_int = 3;
/// 비밀번호가 필요한 문서
pub const JJ_RHWP_E_PASSWORD_REQUIRED: c_int = 4;
/// 비밀번호가 틀림
pub const JJ_RHWP_E_PASSWORD_WRONG: c_int = 5;
/// 쪽 범위가 맞지 않음 (앱 잘못)
pub const JJ_RHWP_E_RANGE: c_int = 6;
/// 쪽을 그리지 못함
pub const JJ_RHWP_E_RENDER: c_int = 7;
/// PDF 를 저장하지 못함
pub const JJ_RHWP_E_WRITE: c_int = 8;
/// 변환기 안의 예기치 않은 오류 (panic)
pub const JJ_RHWP_E_INTERNAL: c_int = 9;
/// 연 문서
pub struct JjRhwpDoc {
core: DocumentCore,
/// 멈추라고 한 요청 번호 (끝나면 지운다)
cancelled: Mutex<HashSet<u64>>,
/// 닫는 중 - 모든 요청을 멈춘다 (되돌리지 않는다)
closing: AtomicBool,
}
impl JjRhwpDoc {
fn stopped(&self, req: u64) -> bool {
self.closing.load(Ordering::SeqCst) || self.cancelled.lock().map(|s| s.contains(&req)).unwrap_or(false)
}
}
struct Fail(c_int, String);
fn text(p: *const c_char) -> Option<String> {
if p.is_null() {
return None;
}
Some(unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned())
}
fn set_out(code: *mut c_int, err: *mut *mut c_char, c: c_int, msg: &str) {
if !code.is_null() {
unsafe { *code = c };
}
if !err.is_null() {
let s = CString::new(msg.replace('\0', " ")).unwrap_or_default();
unsafe { *err = s.into_raw() };
}
}
fn guarded<T>(code: *mut c_int, err: *mut *mut c_char, fail: T, f: impl FnOnce() -> Result<T, Fail>) -> T {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(v)) => {
if !code.is_null() {
unsafe { *code = JJ_RHWP_OK };
}
v
}
Ok(Err(Fail(c, msg))) => {
set_out(code, err, c, &msg);
fail
}
Err(p) => {
let msg = p
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| p.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic".to_string());
set_out(code, err, JJ_RHWP_E_INTERNAL, &format!("rhwp panic: {msg}"));
fail
}
}
}
/// 여는 오류의 종류: rhwp v0.8.7 의 오류 글 (ParseError 의 Display) 로 암호 문서를 가린다
fn open_kind(msg: &str, with_password: bool) -> c_int {
if msg.contains("비밀번호가 필요한 암호 문서") {
JJ_RHWP_E_PASSWORD_REQUIRED
} else if msg.contains("암호 오류") {
if with_password {
JJ_RHWP_E_PASSWORD_WRONG
} else {
JJ_RHWP_E_PASSWORD_REQUIRED
}
} else {
JJ_RHWP_E_INVALID
}
}
/// 이 C ABI 의 판 - Dart (lib/platform/android/hwp_pdf_ffi.dart 의 RhwpEngine.abiVersion) 와 같아야 앱 안 보기를 쓴다
/// (관리자 지시 10/20, 28 지적: 옛 .so (판 1 - code 칸 · cancel 없음) 에 새 인수를 넘기면 힙이 망가진다).
/// 함수의 인수 · 오류 코드를 바꾸면 올린다
pub const JJ_RHWP_ABI_VERSION: c_int = 3;
#[no_mangle]
pub extern "C" fn jj_rhwp_abi_version() -> c_int {
JJ_RHWP_ABI_VERSION
}
/// 판 (예: "0.8.7") - 라이선스 화면 · 작업 기록용. jj_rhwp_free 로 푼다
#[no_mangle]
pub extern "C" fn jj_rhwp_version() -> *mut c_char {
CString::new(option_env!("JJ_RHWP_VERSION").unwrap_or("?")).unwrap_or_default().into_raw()
}
/// 문서 열기 (password 는 null 이나 "" 이면 없음). 실패하면 null 과 code · err
#[no_mangle]
pub extern "C" fn jj_rhwp_open(
path: *const c_char,
password: *const c_char,
code: *mut c_int,
err: *mut *mut c_char,
) -> *mut JjRhwpDoc {
guarded(code, err, null_mut(), || {
let path = text(path).ok_or_else(|| Fail(JJ_RHWP_E_READ, "no path".into()))?;
let data = std::fs::read(&path).map_err(|e| Fail(JJ_RHWP_E_READ, e.to_string()))?;
let pw = text(password).filter(|p| !p.is_empty());
let core = match &pw {
Some(p) => DocumentCore::from_bytes_with_password(&data, p.as_bytes()),
None => DocumentCore::from_bytes(&data),
}
.map_err(|e| {
let msg = e.to_string();
Fail(open_kind(&msg, pw.is_some()), msg)
})?;
Ok(Box::into_raw(Box::new(JjRhwpDoc { core, cancelled: Mutex::new(HashSet::new()), closing: AtomicBool::new(false) })))
})
}
/// 쪽 수 (doc 이 null 이면 -1)
#[no_mangle]
pub extern "C" fn jj_rhwp_page_count(doc: *const JjRhwpDoc) -> c_int {
if doc.is_null() {
return -1;
}
let doc = unsafe { &*doc };
catch_unwind(AssertUnwindSafe(|| doc.core.page_count() as c_int)).unwrap_or(-1)
}
/// 요청 [req] 만 멈춘다 (다른 스레드에서 불러도 됨 - 도는 중이면 다음 쪽에서, 아직 시작 전이면 시작하자마자
/// JJ_RHWP_E_CANCELLED 로 끝난다). 다른 요청은 그대로
#[no_mangle]
pub extern "C" fn jj_rhwp_cancel(doc: *const JjRhwpDoc, req: u64) {
if !doc.is_null() {
if let Ok(mut s) = unsafe { &*doc }.cancelled.lock() {
s.insert(req);
}
}
}
/// 닫기 전에: 도는 요청 · 앞으로 올 요청 모두 멈춘다 (되돌리지 않음)
#[no_mangle]
pub extern "C" fn jj_rhwp_cancel_all(doc: *const JjRhwpDoc) {
if !doc.is_null() {
unsafe { &*doc }.closing.store(true, Ordering::SeqCst);
}
}
/// 요청 [req] (부르는 쪽이 정한 번호 - jj_rhwp_cancel 로 멈출 때 씀): [from, to) 쪽 (0 부터) 을 PDF 파일 out 으로. font_dirs 는 줄바꿈 (\n) 으로 나눈 글꼴 폴더들 (Android 는 시스템 글꼴을
/// 스스로 찾지 않으므로 /system/fonts 와 앱이 받은 한글 글꼴 폴더를 넘긴다). 성공 0, 실패 -1 과 code · err.
/// rhwp 의 render_pages_pdf_native_with_options 와 같게 (Print 로 쪽마다 SVG → 한 PDF) 하되, 쪽마다 멈춤 표시를 본다
/// (하이퍼링크는 넣지 않는다 - 보기용)
#[no_mangle]
pub extern "C" fn jj_rhwp_export_pdf(
doc: *const JjRhwpDoc,
req: u64,
from: c_int,
to: c_int,
font_dirs: *const c_char,
out: *const c_char,
code: *mut c_int,
err: *mut *mut c_char,
) -> c_int {
guarded(code, err, -1, || {
if doc.is_null() {
return Err(Fail(JJ_RHWP_E_RANGE, "no document".into()));
}
let doc = unsafe { &*doc };
// 끝나면 (어떻게 끝나든) 이 요청의 멈춤 표시를 지운다 - 시작할 때는 지우지 않는다 (먼저 온 멈춤 · 닫기를 지키려고)
struct Forget<'a>(&'a JjRhwpDoc, u64);
impl Drop for Forget<'_> {
fn drop(&mut self) {
if let Ok(mut s) = self.0.cancelled.lock() {
s.remove(&self.1);
}
}
}
let _forget = Forget(doc, req);
let count = doc.core.page_count() as c_int;
if from < 0 || to > count || from >= to {
return Err(Fail(JJ_RHWP_E_RANGE, format!("pages {from}..{to} / {count}")));
}
let out = text(out).ok_or_else(|| Fail(JJ_RHWP_E_WRITE, "no output path".into()))?;
let mut options = PdfExportOptions::default();
options.font_paths = text(font_dirs)
.unwrap_or_default()
.split('\n')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.collect();
let cancelled = || doc.stopped(req);
let mut svgs = Vec::with_capacity((to - from) as usize);
for page in from as u32..to as u32 {
if cancelled() {
return Err(Fail(JJ_RHWP_E_CANCELLED, "cancelled".into()));
}
svgs.push(
doc.core
.render_page_svg_layer_with_profile_native(page, RenderProfile::Print)
.map_err(|e| Fail(JJ_RHWP_E_RENDER, format!("page {}: {e}", page + 1)))?,
);
}
if cancelled() {
return Err(Fail(JJ_RHWP_E_CANCELLED, "cancelled".into()));
}
let bytes = svgs_to_pdf_with_options(&svgs, &options).map_err(|e| Fail(JJ_RHWP_E_RENDER, e))?;
if cancelled() {
return Err(Fail(JJ_RHWP_E_CANCELLED, "cancelled".into()));
}
// 다 쓴 뒤에 이름을 바꾼다 (보기 화면이 반쪽 파일을 읽지 않게)
let part = format!("{out}.part");
std::fs::write(&part, &bytes).map_err(|e| Fail(JJ_RHWP_E_WRITE, e.to_string()))?;
std::fs::rename(&part, &out).map_err(|e| Fail(JJ_RHWP_E_WRITE, e.to_string()))?;
Ok(0)
})
}
/// 문서 닫기
#[no_mangle]
pub extern "C" fn jj_rhwp_close(doc: *mut JjRhwpDoc) {
if !doc.is_null() {
let _ = catch_unwind(AssertUnwindSafe(|| drop(unsafe { Box::from_raw(doc) })));
}
}
/// 이 라이브러리가 돌려준 글 (err · 판) 풀기
#[no_mangle]
pub extern "C" fn jj_rhwp_free(s: *mut c_char) {
if !s.is_null() {
drop(unsafe { CString::from_raw(s) });
}
}

View File

@ -55,9 +55,15 @@ if ($left.Count -gt 0) {
Write-Host ("시험이 남긴 임시 폴더 {0}개 ({1}):" -f $left.Count, $run)
$left | Sort-Object | ForEach-Object { Write-Host " $_" }
}
# 실행 폴더는 이 스크립트가 만든 것이라 통째로 지운다 (긴 경로는 \\?\ 로)
cmd /c "rd /s /q `"\\?\$run`"" 2>$null
if (Test-Path -LiteralPath $run) { Write-Host "실행 폴더를 다 지우지 못함: $run" }
# 실행 폴더는 이 스크립트가 만든 것이라 통째로 지운다 (긴 경로는 \\?\ 로).
# 관리자 지시 (10/20): Edge (WebView2) 는 시험 앱이 끝난 뒤에도 몇 초 사용자 데이터 (jj_held_) 를 잡고 있다 - 놓을 때까지 몇 번 다시 해 본다.
# rd 의 오류는 cmd 안에서 버린다 (PowerShell 쪽 2>$null 은 $ErrorActionPreference = 'Stop' 에서 오류가 되어 시험이 다 통과해도 exit 1 이었다).
# 지우는 것은 이 실행 폴더 안뿐이다 (사용자 폴더 · 다른 세션의 %TEMP% 는 건드리지 않음)
for ($i = 0; $i -lt 30 -and (Test-Path -LiteralPath $run); $i++) {
if ($i -gt 0) { Start-Sleep -Seconds 2 }
cmd /c "rd /s /q `"\\?\$run`" 2>nul"
}
if (Test-Path -LiteralPath $run) { Write-Host "실행 폴더를 다 지우지 못함 (1분 기다림): $run" }
if ($left.Count -gt 0) {
if ($code -eq 0) { exit 2 }
} else {