Compare commits

...

44 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
RyuJongJin dfed3fa6bb 좁은 Rsync 화면: [오른쪽] 탭 · 넓게 바꾼 두 창도 그 창의 지금 폴더가 보임 · 옵션 이름이 든 제목 · 체크가 "(--" 에서 갈리지 않음 (검토 대기)
관리자 지시 (10/20, 28 이 983 에서 찾음):
1. 412 Rsync 화면의 [오른쪽] 탭: 탭 이름은 지금 폴더인데 목록은 저장 장치 맨 위에서 시작했다 (넓게 바꿔도 그대로).
   원인: 켤 때 _goTo 가 두 창 모두 지금 폴더까지 펼치고 스크롤하는데, 좁은 화면은 왼쪽 창만 그려 오른쪽 창의 스크롤은 아직
   화면에 붙지 않아 건너뛰었다. 고침: 창의 목록이 화면에 새로 붙을 때마다 (탭을 처음 볼 때 · 폭이 바뀌어 두 창이 될 때) 그 창의
   지금 폴더를 보이게 (_revealNewViews · _revealNow). 폭이 바뀌면 LayoutBuilder 만 다시 불리므로 그 안에서 부른다.
2. en 412 확인 창의 체크 제목 "Delete source files (--remove-source-files)" 가 "(--" 에서 갈렸다. keepOptionsTogether: 글 안의 옵션
   덩어리만 (괄호째 · 따로 쓴 --이름 · -u · /PURGE) unbreakableOption 으로 묶고 나머지는 그대로 줄바꿈. 옵션 이름이 든 다른 제목 ·
   체크 · 설명에도: 받는 쪽에서 n개가 지워집니다 (--delete …) · 거른 것 n개도 지워집니다 (--delete-excluded) 와 그 설명 ·
   ⇄ 의 (-u) · 받는 쪽이 더 새것이면 건너뛰기 (-u · /XO) · 원본에 없는 것을 대상에서 지우기 (--delete · /PURGE).
시험: explorer_parallel_test (28 장면: 맨 위에 폴더 80개 · 지금 폴더는 깊은 곳 - 왼쪽 · [오른쪽] 탭 · 넓게 · 다시 좁게 모두 지금 폴더가
보임, 고치기 전에는 [오른쪽] 탭에서 실패) · sync_preview_view_test (keepOptionsTogether). 이 글을 찾는 시험 5개는 같은 도우미를 거친
글로. 캡처 windows_rsync_narrow_shot_test ([오른쪽] 탭 · 확인 창, ko · en · ja × 412 · 960, 넘침 0) → release\test\shots_rsync_narrow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:39:07 +09:00
RyuJongJin f947935c19 next9: + msys_args 끝 (634fcbc · 849c830: 목록 · 기록 · 배치 파일의 상대 경로 막기) - 시험판 985
합침 (d3): 상대 경로 막기를 optionProblems 의 새 종류 relativePath 로 (안내 끝 문구 "드라이브 (C:) 나 / 로 시작하는 경로로", 내 판단).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:14:23 +09:00
RyuJongJin 849c83031d 기록 · 배치 파일 (--log-file · --write-batch · --read-batch · --only-write-batch) 의 상대 경로도 막음 (검토 대기)
관리자 지시 (10/20, 28 확인): 상대 경로면 응답 파일 임시 폴더에 생겼다가 함께 지워져 사용자가 기록을 찾지 못함 - "기록 · 배치 파일은 전체 경로로 적어 주세요". --backup-dir · --partial-dir · --temp-dir · --compare-dest 류는 받는 쪽 기준이라 그대로 (시험에 둠). 번역 en · ja · zh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:10:55 +09:00
RyuJongJin 634fcbc920 목록 파일 (--exclude-from · --include-from · --files-from · ". 파일" · merge) 의 상대 경로는 막음 - "목록 파일은 전체 경로로 적어 주세요" (검토 대기)
관리자 판단 (10/20, 7160577 에 이어): rsync 는 자기 작업 폴더 (Windows 는 응답 파일 폴더) 기준으로, 미리 보기는 앱의 작업 폴더
기준으로 상대 경로를 읽어 서로 다른 목록 파일을 볼 수 있다. unreadableOptions 가 막는다 (옵션 칸 · 확인 창 · 모니터링 빨갛게, 실행 ·
실시간 동기화 막음).
- 대상: --exclude-from · --include-from · --files-from (= 와 다음 낱말), --filter / -f (붙여 · 다음 낱말 · 짧은 묶음 -avf) 의
  ". 파일" · "merge[,고치개] 파일". 전체 경로 = C:\ · C:/ · \서버 · / (/cygdrive/… · Android).
- 빼는 것: ": 이름" · dir-merge · -F (폴더마다 읽는 규칙 파일 이름 - 원래 상대), --exclude 의 값 (패턴).
시험: rsync_msys_args_test (막는 것 10 · 막지 않는 것 11). dismiss_test · live_sync_scene_test 의 merge 규칙을 전체 경로로.
거르기 · 맞추기 · 실시간 · 복사 시험 25개 파일 382개 통과.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:03:29 +09:00
RyuJongJin bd0801ce66 next9: + msys_args (7160577: Windows rsync 인수를 응답 파일로 · MSYS=noglob - ' 가 인수를 합쳐 거르기가 사라지던 것) - 시험판 985
합침 (d3): unreadableOptions 는 optionProblems 를 거친다 (b1a43c3 의 종류별 안내). 15 의 windows 매개변수 · 큰따옴표 막기를
optionProblems 로 옮기고, 안내는 새 종류 doubleQuote "큰따옴표 (\") 를 빼 주세요" (내 판단).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:59:12 +09:00
RyuJongJin 716057734a P0 Windows rsync: 인수를 응답 파일 (@) 로 넘기고 MSYS=noglob - ' 짝짓기 · 글로브 펼치기로 거르기가 사라져 지킬 파일이 지워지던 것 (검토 대기)
관리자 지시 (10/20, 28 재현 - 데이터, 정식 판에도 있음): Windows 의 rsync (MSYS2 런타임, 3.5.1) 는 Windows 프로그램이 넘긴
명령 줄을 스스로 다시 읽는다. 앱의 인수는 맞는데:
- ' 를 따옴표로 짝지어 인수를 합침: -a --delete --exclude="it's.txt" --exclude="Bob's.txt" 가 한 인수가 되어 거르기가 사라지고
  it's.txt · Bob's.txt 가 지워짐 (미리 보기는 "남음"). ' 하나면 rsync 가 실패, ' 가 든 경로 (한쪽 · 양쪽) 는 합쳐져 실패.
- * ? [ ] { } 를 작업 폴더의 파일로 펼침: --exclude *.log 가 작업 폴더의 x.log y.log 로 바뀌어 거르기가 빗나감.
실험 (실제 rsync): MSYS=noglob 은 글로브만 막고 ' 짝짓기는 그대로 (CYGWIN=noglob 은 소용없음). Dart 는 빈칸 · 큰따옴표가 없는
인수를 따옴표로 감싸지 않고, 감쌀 때의 \" 를 MSYS 는 다르게 읽어 명령 줄로는 고칠 수 없음. 응답 파일에 인수마다 큰따옴표로 감싸
넣고 (안의 \ 는 그대로) "@args.txt" 로 넘기면 ' · * ? [ ] { } · 빈칸 · 한글 · \ · @ 가 모두 그대로 감. 응답 파일 경로에 빈칸 · ' 가
있으면 @ 가 먹히지 않아 그 폴더를 작업 폴더로 하고 상대 이름으로 넘김.
- lib/core/rsync_launch.dart: prepareRsyncLaunch (Windows 만 - Android 는 그대로) · 끝나면 지움. 기존 MSYS 값에 noglob 을 더함.
- 띄우는 곳 둘 다: TransferJob (복사 · Rsync 화면 · 모니터링 [실행] · 최종 정리 - robocopy 는 그대로) · LiveSync (실시간 동기화).
  미리 보기는 rsync 를 쓰지 않는다.
- 큰따옴표 (") 가 든 인수는 응답 파일로도 그대로 못 넘김 (Windows 파일 이름에는 없음): 옵션 칸 · 확인 창 · 모니터링에 빨갛게
  (unreadableOptions, Windows 만) · 띄울 때도 막고 이유를 보임.
- 알아 둘 것: rsync 의 작업 폴더가 응답 파일 폴더가 되어, 옵션의 상대 경로 (--exclude-from=목록.txt) 는 그 폴더 기준 (예전에도
  앱의 작업 폴더라 정해지지 않았음 - 절대 경로를 쓰는 것이 맞음).
시험 rsync_msys_args_test (실제 rsync): ' 두 개 · 하나의 --exclude, ' 가 든 원본 경로 · 원본 · 대상 경로 · 경로 + exclude, 따로 쓴 *
· ? · [ ] · { }, [ ] · { } · 빈칸 · 한글 경로, 실시간 동기화 쌍, 응답 파일 지우기, 도우미 · 큰따옴표 막기. 고치기 전에는 7개 실패.
거르기 · 맞추기 · 실시간 · 복사 시험 25개 파일 · rsync_page_test (Windows 통합) 통과.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:52:26 +09:00
RyuJongJin b1a43c36c5 WIP 옵션 칸 보충 (관리자 지시 10/20, 28 지적): --cc · --zc · --zl · --zt 등 값 받는 별칭 (오탐) · -–delete 처럼 하이픈과 대시가 섞인 것도 고침 · 안내 끝 문구를 문제 종류에 맞게 (따옴표 닫기 · - 로 시작 · 값 적기 · 곧은 글자로) · 옵션 낱말은 줄바꿈 없이 · 캡처 시험 windows_option_problem_shot_test (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:46:57 +09:00
RyuJongJin f45ad3b25f WIP 옵션 칸 틈 셋 (관리자 판단 10/20, 28 확인): 값 자리가 아닌데 - 로 시작하지 않는 낱말 ("경로로 읽힙니다") · 하이픈 셋 이상 · 끝에 값 없는 옵션 ("--exclude 의 값이 없습니다") 도 알아볼 수 없음으로 실행을 막음 - 실제 rsync 는 원본 경로로 먹어 --delete 가 빠지거나 아무것도 보내지 않음 (시험으로 확인) (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:06:04 +09:00
RyuJongJin 55caef5c3f WIP 옵션 붙여 넣기 보충 (15 검토 F1~F5, 관리자 지시 10/20): 닫히지 않은 따옴표는 뒤 옵션을 삼키지 않고 "따옴표가 닫히지 않았습니다" 로 실행을 막음 (데이터 - 예전에는 뒤의 --exclude 가 사라져 지킬 파일이 지워짐, 곧은 따옴표도) · 대시 하나는 rsync 긴 옵션 이름일 때만 -- (–avz → -avz) · --filter · -f 값의 규칙 머리 대시를 - 로 · 값 자리 대시는 그대로 · “a b”.txt 처럼 닫는 따옴표 뒤에 글이 이어져도 닫음 · 시험 파일의 보이지 않는 글자를 \u 표기로 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:52:05 +09:00
RyuJongJin 0d86a9b163 next9: + gb_flaky (60b994a: settings_group_bar_test 흔들림, 시험만)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:42:19 +09:00
RyuJongJin 60b994a9e6 시험 흔들림: settings_group_bar_test (412 묶음 바로가기) - 화면이 멈출 때까지 기다리고 치운 뒤에도 남은 일을 끝냄 (검토 대기)
관리자 지시 (10/20): 67개 시험 파일을 한꺼번에 돌릴 때 한 번 실패 (혼자서 · 무거운 시험과 함께 · 67개 다시 663개 모두 재현되지 않음). 실패 줄에 Expected · Timeout 이 없어 값이 틀린 것이 아니라 시험 밖으로 넘어간 예외로 봄: 설정 화면의 칸들이 시작한 진짜 입출력 (레지스트리 조회 · 구성 요소 목록 HttpClient · 파일) 이 부하가 크면 한 번 그린 뒤 · 화면을 치운 뒤에야 끝남. 이제 재기 전과 치운 뒤에 화면이 멈출 때까지 (위젯 수가 세 번 같음) 진짜 시간을 흘리고, 그만큼 시험 시간을 2분으로. 코드 변경 없음.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:39:20 +09:00
RyuJongJin 2bacd88925 WIP 시험 흔들림: 멈춘 마그넷 줄의 "n초째" 를 61~99초로 (전체 시험 부하에서 64초가 나와 실패) (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:09:37 +09:00
RyuJongJin 484f11d9c2 next9: + remote_idle (7f2263b: 원격 서버가 답하지 않으면 정한 시간 뒤 실패 · File closed 가 이유를 덮던 것) - 시험판 983
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:57:25 +09:00
RyuJongJin d2dc267ae2 WIP 옵션 칸에 붙여 넣은 글을 곧은 글자로 쓴 것과 같게 읽음 (관리자 지시 10/20, 28 확인, 데이터): 굽은 따옴표는 낱말 앞 · = 뒤 · -f 뒤에서 따옴표로, 낱말 맨 앞 대시 (– — ‑ - 등) 는 - · -- 로, 소프트 하이픈 · 전각 빈칸 고침 - 예전에는 --exclude=‘*.log’ 가 거르지 못해 받는 쪽 keep.log 가 지워졌고 –delete 는 원본 경로로 넘어가 --delete 가 빠졌다 · 그래도 알아볼 수 없는 옵션이 남으면 확인 창 · 설정 칸 · 모니터링에 빨갛게, 실행 · 실시간 동기화는 막음 · 시험 28 의 13가지 (실제 rsync 결과 = 곧은 글자) (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:57:25 +09:00
RyuJongJin 7f2263bbb9 원격 서버가 멈추면 정한 시간 (처음 60초) 뒤 실패 - FTP · SFTP · SMB · WebDAV 받기 · 올리기 · 명령 (검토 대기)
관리자 판단 (10/20): 서버가 중간에 멈추면 복사 · 동기화가 영원히 멈추던 것 (15 가 FTP 에서 찾음 - 명령의 답에만 시간 제한).
- lib/core/remote_idle.dart: idleRead (조각 사이) · idleWrite (보내는 쪽이 한 조각도 더 가져가지 않거나 다 보낸 뒤 답이 없음) ·
  idleCall (요청 하나). 넘으면 그 연결을 끊고 RemoteIdleTimeout "서버가 답하지 않습니다 (n초)".
- FTP: 자료 연결 받기 (await for) · 올리기 (addStream). SFTP: 파일 읽기 · 쓰기와 모든 명령 (목록 · 열기 · 닫기 · 지우기 등) -
  멈추면 그 연결을 닫고 다음에 다시 연다. SMB: 조각 읽기 · 쓰기 채널 호출. WebDAV: 받기 · 올리기와 모든 요청의 답 (머리 · 몸).
- 올리다 실패하면 서버의 받다 만 .jjpart 지우기도 정한 시간까지만 (못 지우면 다음 정리 71).
- 설정 "원격 서버 응답 기다리기" (환경 설정 > 파일 탐색기): 30 · 60 (처음) · 120 · 300초, 그 밖의 값은 60. 번역 en · ja · zh.
- 버그 수정 (멈추는 서버 시험으로 찾음): 원격에서 로컬로 받다 실패하면 임시 파일을 닫고 지운 뒤 finally 에서 한 번 더 닫다가
  난 "File closed" 가 진짜 이유를 덮어썼다 (vWrite) - 이제 원래 이유 그대로.
시험 remote_idle_test: 멈추는 시험 서버로 정한 시간 (시험은 1초) 뒤 실패 - FTP (Dart 로 만든 서버: 보내다 멈춤 · 받다 멈춤,
로컬에 받다 만 파일 없음), WebDAV (머리 없음 · 몸 멈춤 · 받기 멈춤 · .jjpart 지우기 부탁), SFTP (paramiko + 멈추는 중계: 받는 중 ·
열기 · 올리는 중), 도우미 · 설정. smb_test: 가짜 채널이 답하지 않음 (열린 것은 닫음).
캡처 windows_remote_idle_shot_test (ko · en · ja × 412 · 960, 넘침 0) → release\test\shots_remote_idle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:46:42 +09:00
RyuJongJin f5a01a1ecd next9: + filter_phsr 끝 (d7da812: P keep/*** 가 받는 쪽에만 있는 안쪽 폴더도 지킴) - 시험판 982
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:18:37 +09:00
RyuJongJin 0ef2c2c33b next9: + filter_phsr (c593440: H · S · P · R · ,s · ,r 을 rsync 와 같게 따름) - 시험판 982
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:16:35 +09:00
RyuJongJin d7da812da6 거르기: "이름/***" 을 경로로 맞춤 - "P keep/***" 가 받는 쪽에만 있는 안쪽 폴더까지 지킴 (검토 대기)
28 확인 (10/20, c593440 독립 확인 22개 중 1개 - 안전한 쪽으로 다름): -a --delete --filter="R keep/extra.txt" --filter="P keep/***",
받는 쪽에만 keep/extra.txt · keep/sub/deep.txt 가 있으면 실제 rsync 는 keep/extra.txt 만 지우는데, 미리 보기 · 실시간 동기화의
묻는 목록은 keep/sub/ 까지 지운다고 보였다 (맞춘 뒤에도 지울 것으로 남음).
원인: "이름/***" 의 끝을 떼고 나면 패턴에 / 가 없어 이름으로만 맞췄다 - keep/sub 는 이름이 sub 라 맞지 않음. 원래 패턴에 / 가
있으므로 rsync 처럼 경로 (끝부분) 로 맞춘다. 보내는 쪽에서는 거른 폴더 안으로 내려가지 않아 드러나지 않았다.
rsync_phsr_test 에 28 의 장면 (keep/k.txt · 받는 쪽 keep/extra.txt · keep/sub/deep.txt) 과 *** 장면 4개 - 고치기 전에는 32 · 33 번 실패.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:15:50 +09:00
RyuJongJin 57676371dc WIP 옵션 글의 줄바꿈 없는 빈칸 · 보이지 않는 글자 (관리자 지시 10/20): 옵션을 나눌 때 (splitOptions) 와 옵션 칸에 붙여 넣을 때 (OptionTextFormatter) 보통 빈칸으로 / 지움 - 묶은 글 · 다른 곳의 글을 붙여 넣어도 rsync 가 같은 인수로 읽음 · 소스의 보이지 않는 글자는 \u 표기로 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:05:26 +09:00
RyuJongJin c593440249 거르기: H · S · P · R 과 고치개 ,s · ,r 을 rsync 와 똑같이 따름 - 미리 보기 · 원격 맞추기 · 실시간 동기화 (검토 대기)
관리자 지시 (10/20, 데이터 쪽): 예전에는 이 규칙들을 "미리 볼 수 없음" 으로 두고 경고만 했다. 이제 rsync (man rsync FILTER RULES ·
exclude.c) 와 같게:
- 규칙마다 걸리는 쪽 (RuleSide): 그냥 + · - 는 양쪽, H (hide) = -,s · S (show) = +,s 는 보내는 쪽 (보낼 것 고르기) 에만,
  P (protect) = -,r · R (risk) = +,r 은 받는 쪽 (지울 것 고르기) 에만. ",sr" 은 양쪽. 긴 이름 (hide · show · protect · risk) 도.
- 쪽마다 그 쪽에 걸리는 규칙만 위에서부터 보고 처음 맞는 것이 이긴다 (RsyncFilter.excluded = 보내는 쪽, protects = 받는 쪽).
- --delete-excluded 면 쪽을 적지 않은 규칙은 보내는 쪽에만 (rsync 와 같음 - 예전 동작과도 같은 결과).
- 지울 때 (previewSync · _mirrorV · 실시간 동기화 diff/_gone) 는 "거른 것 · --delete-excluded 아님" 대신 protects 로 지킨다:
  보내는 쪽에서만 거른 것 (H · -,s) 은 보내지 않았으므로 --delete 면 지운다 (28 의 a.txt 와 같은 일을 이제 미리 보기가 보임).
- 그 밖의 고치개 (,! · ,p · ,x · ,e 등) · H/S/P/R 에 붙은 고치개 · merge · "!" · exclude-from 파일 안의 규칙 머리는 그대로 미리 볼 수 없음.
기준 시험 rsync_phsr_test: 규칙 장면 31개 (양쪽에 같은 이름이 많은 장면) 마다 실제 rsync 를 돌린 (-n 아님) 결과 트리와
① 미리 보기로 예측한 트리 ② 원격 맞추기 (WebDAV) ③ 실시간 동기화 로컬 쌍 (묻는 목록 = rsync 가 지운 것 · 맞춘 뒤 맞출 것 없음)
④ 실시간 동기화 WebDAV 쌍 - 모두 같음. protects 를 예전처럼 (쪽 무시) 바꾸면 25개 장면이 다르게 나오는 것도 확인 (시험이 잡음).
바꾼 시험: rsync_filter_test (이 규칙들은 미리 볼 수 있음 · 15 재현은 이제 미리 보기도 a.txt 지움), P0 시험 · 경고 창 시험 ·
P0 캡처는 아직 따르지 못하는 merge 규칙 (파일 안에 "H a.txt") 으로, P 는 이제 따르므로 "묻는 목록에 지킨 것 없음" 으로.
smb_test 의 28 장면 시험에 5분 (여러 시험을 함께 돌리면 기본 30초를 넘음).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:04:28 +09:00
RyuJongJin 0bd1466212 WIP 실시간 동기화 화면 (관리자 지시 10/20, 28 지적): 경고 글 안의 옵션 (--filter=H a.txt) 을 줄바꿈 없이 묶음 · 좁은 화면에서는 카드의 "rsync · 복사로 맞춤 · 지우기 포함" 칩을 경로 아래 한 줄로 (잘리던 것) (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:50:49 +09:00
RyuJongJin b2c400a0fb WIP 시험: 켤 때 지우는 것은 앱이 업데이트 때 만든 이름만 - 다른 jj 이름 (앱 임시 폴더 · 도구 받기 · Chrome 엔진 · 비슷한 이름) 은 남김 (관리자 지시 10/20, 검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:35:16 +09:00
RyuJongJin f6de74010c next9: + lsync_p0 (aba095b: 미리 볼 수 없는 거르기 규칙의 지우기 포함 쌍은 저절로 지우지 않음) - 시험판 981
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:25:15 +09:00
RyuJongJin e228e84adb WIP 시험 · 앱이 %TEMP% 에 고정 이름을 남기지 않게 (관리자 지시 10/20): 시험의 앱 임시 폴더는 파일마다 jj_store_ (flutter_test_config) · 업데이트 시험은 시험 폴더 (GitHubUpdater.tempRoot, 기록은 스크립트 옆) · check.py 는 임시 폴더 · 앱이 켜질 때 지난 업데이트가 남긴 새 판 폴더 · .ps1 · .log 를 지움 (2분 지난 것) · 검사는 jj 로 시작하는 것 모두 실패 · 마그넷 멈춘 막대 "Stalled" 따로 열쇠 (28 지적) · explorer_test 82 부하 때 목록 기다리기 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:25:15 +09:00
RyuJongJin aba095b342 P0 실시간 동기화: 미리 볼 수 없는 거르기 규칙이 있는 지우기 쌍은 저절로 지우지 않고 보내기만 · 지우기는 확인 뒤 한 번 (검토 대기)
관리자 3a 지시 (10/20, 28 재현 - 데이터 버그 수정): 로컬 rsync 쌍 · 지우기 포함 · 옵션 --filter="-,s a.txt" (또는
"H a.txt" · -f"H a.txt") 에서 묻는 목록은 [extra.txt, old.log] 인데 [지우기] 를 누르면 a.txt 도 지워졌다 (실제 rsync 는
보내는 쪽만 빼기 · 숨기기를 받는 쪽에서 지울 것으로 봄). "P *.txt" 는 반대로 지킨 것을 끝없이 물었다.
- 지우기 포함 rsync 쌍의 옵션에 미리 볼 수 없는 규칙 (RsyncFilter.unsupported) 이 하나라도 있으면 자동 맞추기는 --delete
  계열 없이 (보내기만) 하고 묻지도 않는다 (목록이 실제와 달라서). 맞출 것 · 대상에만 있는 것도 지우기 없이 센다.
- 카드에 빨간 "이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다 (규칙)" · [지금 지우기 (확인)].
  그 창은 FilterLimitNote (빨간 "실제로 더 지워질 수 있습니다") 와 [알고 실행합니다] 체크가 있어야 [지우고 맞추기] -
  이번 한 번만 지우고 다음 자동 맞추기는 다시 보내기만.
- 묻는 창 (confirmLiveDeletes) 도 같은 창을 써서 규칙이 있으면 FilterLimitNote · 체크.
- P (지키기) 는 따로 따르지 않고 위와 같이 다룸 (경고 · 저절로 지우지 않음 · 끝없이 묻지 않음) - 지킨 것을 목록에서 빼려면
  P · R 과 다른 규칙의 순서 (받는 쪽 규칙) 를 맞춰야 해서 이번에는 하지 않음.
시험: live_sync_scene_test P0 - 세 가지 규칙 × 새 쌍 · 확인한 쌍을 실제 rsync 로 (먼저 실제 rsync 가 a.txt 를 지우는 것 확인
→ 자동 두 번은 아무것도 지우지 않음 · 묻지 않음 · − 줄 없음 → 지금 지우기 한 번 → 다음 자동은 다시 보내기만), P 규칙 세 번,
미리 볼 수 있는 규칙만이면 예전처럼, 창 (빨간 줄 · 체크 전 버튼 꺼짐 · [지우지 않기] 없음). LiveSync 시험 157개 통과.
캡처: windows_lsync_p0_shot_test (카드 · 지금 지우기 창, ko · en · ja × 412 · 960, 넘침 0) → release\test\shots_lsync_p0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:21:56 +09:00
RyuJongJin 2f4ad6c146 WIP 시험 임시 폴더 검사 보충: 실패는 시험 소스 이름의 폴더만 (앱 고정 이름 jj_mkvmaker · 파일은 참고로 보이고 함께 지움) · explorer_test 의 -u 찾기를 옵션 낱말로 (시험 폴더 위가 %TEMP% 라 vscode-stable-user-x64 에 걸림) (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:57:50 +09:00
RyuJongJin f377a14184 next8: + tmptag (시험 임시 폴더 검사 표)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:39:47 +09:00
RyuJongJin 84863c1569 WIP 시험 임시 폴더 검사: 실행마다 %TEMP%\t_jjrun_<표> 로 TEMP · TMP 를 돌려 그 안만 셈 - 다른 세션 시험과 섞여 거짓 실패하던 것 · 앱 코드가 남긴 것도 잡음 · 도우미가 표 밖에 생기면 실패 · Edge 데이터 폴더 (앱이 끝날 때까지 잡힘) 는 jj_held_ 로 보이기만 하고 함께 지움 (관리자 지시 10/20, 검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:39:46 +09:00
RyuJongJin 6e646b60c1 next8: + a8bc585 (실시간 동기화: 옵션이 바뀌어 지울 것이 늘면 다시 묻기) - 시험판 980
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:30:18 +09:00
RyuJongJin 2033c48e5e next8: + magnet_ui (197ba04: dn 이름 제목 · 멈춘 줄 막대) - 시험판 980
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:24:16 +09:00
RyuJongJin a19d68e167 next8: next7 + next6 (44a6c4e: 실시간 동기화 거르기 · --delete-excluded) - 시험판 980
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:24:14 +09:00
RyuJongJin 197ba047c4 마그넷 줄: 메타데이터 전에도 dn 이름 · 멈춘 줄은 막대가 움직이지 않고 주황 "멈춤" (검토 대기)
관리자 지시 (10/20, 28 이 next5 6b60f34 확인에서 찾음):
1. 마그넷에 이름 (dn=…) 이 있으면 메타데이터를 받기 전에도 줄 제목에 그 이름 (magnetName: 주소 글 풀기 · + 는 빈칸 ·
   줄바꿈 · 제어 문자는 빈칸, 없으면 예전처럼 주소). 메타데이터를 받으면 토렌트 이름으로 바뀌는 것은 그대로.
2. 멈춘 줄 (주황 "트래커 · 피어에 닿지 않습니다") 의 진행 막대: 움직이는 "준비 중…" 대신 멈춘 막대 · 주황 · "멈춤"
   (이미 있는 번역 "Stopped" · "停止" · "已停止"). 멈춤이 풀리면 원래대로.
시험: downloads_ui_test (magnetName · 줄 제목 · 멈춤 막대 value 0 · pumpAndSettle 이 끝남 = 움직이지 않음).
캡처: integration_test/windows_magnet_shot_test (ko · en · ja × 412 · 960, 넘침 0) → release\test\shots_magnet_ui.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:01:13 +09:00
RyuJongJin 8cce03d99c next7: next5 (6b60f34) + next6 (83e9fdd) 합침 - 시험판 979 2026-10-20 11:46:19 +09:00
RyuJongJin 6b60f348e2 WIP 마그넷: 인터넷 IPv6 가 없으면 (Tailscale ULA 뿐) aria2 의 IPv6 끔 - 시작되지 않던 것 (28 확인) · 정한 시간 (처음 60초) 동안 메타데이터 · 피어가 없으면 줄에 이유 후보 · [다시 시도] · 실제 마그넷 받기 통합 시험 (JJ_REAL_MAGNET=1) (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:32:40 +09:00
RyuJongJin f13de6b1d8 WIP 177 보충 5 (28 확인): 짧은 옵션 묶음을 rsync (popt) 처럼 한 글자씩 풂 - -avf"- *.log" · -rf "- x" 의 f 규칙을 따르고, 패턴 안의 C · F 를 -C · -F 로 잘못 보지 않음 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:18:48 +09:00
86 changed files with 3834 additions and 325 deletions

View File

@ -73,6 +73,7 @@
"- 감지한 언어": "- detected", "- 감지한 언어": "- detected",
"-1 은 아무렇게나": "-1 = random", "-1 은 아무렇게나": "-1 = random",
".{0} 재생 프로그램 (exe)": ".{0} player (exe)", ".{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 = 제한 없음. 모든 방법에 적용: 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", "0 으로": "Reset to 0",
"1/2 · AI 그림으로 다듬는 중": "1/2 · Refining with AI image", "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 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "Restored previous settings from Android Auto Backup ({0} videos · {1} WebDAV servers)",
"Android: Tor 화면이 열려 있는 동안": "Android: while the Tor screen is open", "Android: Tor 화면이 열려 있는 동안": "Android: while the Tor screen is open",
"BPE 토크나이저가 아닙니다: {0}": "Not a BPE tokenizer: {0}", "BPE 토크나이저가 아닙니다: {0}": "Not a BPE tokenizer: {0}",
"C:\\ 처럼 드라이브로 시작하는 전체 경로로": "use a full path starting with a drive, like C:\\",
"Chrome (내려받기)": "Chrome (download)", "Chrome (내려받기)": "Chrome (download)",
"Chrome (내장)": "Chrome (built-in)", "Chrome (내장)": "Chrome (built-in)",
"Chrome 에서 가져오기": "Import from Chrome", "Chrome 에서 가져오기": "Import from Chrome",
@ -166,6 +168,7 @@
"Chrome 엔진을 시작하지 못했습니다": "Couldn't start the Chrome engine", "Chrome 엔진을 시작하지 못했습니다": "Couldn't start the Chrome engine",
"Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "Cannot start the Chrome engine. Switch to Edge in Settings.\n{0}", "Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "Cannot start the Chrome engine. Switch to Edge in Settings.\n{0}",
"Chrome 엔진의 연결 통로를 다시 열 수 없습니다. 프로그램을 다시 시작하세요.": "Couldn't reopen the Chrome engine's connection path. Restart the app.", "Chrome 엔진의 연결 통로를 다시 열 수 없습니다. 프로그램을 다시 시작하세요.": "Couldn't reopen the Chrome engine's connection path. Restart the app.",
"DNS 강제 (환경 설정 > 네트워크 · DNS)": "Forced DNS (Settings > Network · DNS)",
"DNS 강제 프록시 시작: 127.0.0.1:{0} ({1})": "DNS-forcing proxy started: 127.0.0.1:{0} ({1})", "DNS 강제 프록시 시작: 127.0.0.1:{0} ({1})": "DNS-forcing proxy started: 127.0.0.1:{0} ({1})",
"DNS 강제 프록시: 비밀값이 없는 연결을 거절 ({0})": "DNS proxy: rejected a connection without the secret ({0})", "DNS 강제 프록시: 비밀값이 없는 연결을 거절 ({0})": "DNS proxy: rejected a connection without the secret ({0})",
"DNS 강제 프록시: 비밀값이 틀린 연결을 거절 ({0})": "DNS proxy: rejected a connection with a wrong secret ({0})", "DNS 강제 프록시: 비밀값이 틀린 연결을 거절 ({0})": "DNS proxy: rejected a connection with a wrong secret ({0})",
@ -355,6 +358,7 @@
"VLC 로 열기": "Open with VLC", "VLC 로 열기": "Open with VLC",
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} failed: {1} {2}{3}", "WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} failed: {1} {2}{3}",
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "Keeps WebDAV and OpenSubtitles passwords in this device's secure storage. When off, nothing is saved (existing ones are removed) and you enter them each time the app starts", "WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "Keeps WebDAV and OpenSubtitles passwords in this device's secure storage. When off, nothing is saved (existing ones are removed) and you enter them each time the app starts",
"WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)": "If nothing is sent or received for this long while downloading or uploading over WebDAV · SFTP · FTP · SMB, it fails with a notice (so it does not wait forever when the server stops)",
"WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV, SFTP, SMB, FTP and OpenSubtitles passwords, SFTP keys and API keys. Server lists and user names are kept", "WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV, SFTP, SMB, FTP and OpenSubtitles passwords, SFTP keys and API keys. Server lists and user names are kept",
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).": "WebDAV · SFTP · SMB · FTP: the app syncs these itself instead of rsync (compares size and modified time; follows -u, --delete, --remove-source-files and the filters --exclude, --include, --filter, --delete-excluded from the options).", "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).": "WebDAV · SFTP · SMB · FTP: the app syncs these itself instead of rsync (compares size and modified time; follows -u, --delete, --remove-source-files and the filters --exclude, --include, --filter, --delete-excluded from the options).",
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV · SFTP · SMB · FTP: the app syncs these itself instead of rsync (compares size and modified time; follows -u, --delete and --remove-source-files from the options).", "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV · SFTP · SMB · FTP: the app syncs these itself instead of rsync (compares size and modified time; follows -u, --delete and --remove-source-files from the options).",
@ -524,6 +528,7 @@
"{0} 을(를) 설치했습니다.": "Installed {0}.", "{0} 을(를) 설치했습니다.": "Installed {0}.",
"{0} 을(를) 지울까요?": "Delete {0}?", "{0} 을(를) 지울까요?": "Delete {0}?",
"{0} 의 SHA256 이 다릅니다 (손상되었거나 다른 파일). 설치를 멈췄습니다.": "The SHA256 of {0} does not match (damaged or a different file). Installation stopped.", "{0} 의 SHA256 이 다릅니다 (손상되었거나 다른 파일). 설치를 멈췄습니다.": "The SHA256 of {0} does not match (damaged or a different file). Installation stopped.",
"{0} 의 값이 없습니다": "{0} has no value",
"{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} is empty (insert a card, USB drive or disc and tap again).", "{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} is empty (insert a card, USB drive or disc and tap again).",
"{0} 이상": "{0} or larger", "{0} 이상": "{0} or larger",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.",
@ -609,6 +614,7 @@
"{0}장을 올려 저장했습니다{1}": "Upscaled and saved {0}{1}", "{0}장을 올려 저장했습니다{1}": "Upscaled and saved {0}{1}",
"{0}줄": "{0} lines", "{0}줄": "{0} lines",
"{0}초": "{0} s", "{0}초": "{0} s",
"{0}초 (기본)": "{0} s (default)",
"{0}초 안에 준비되지 않았습니다.": "It wasn't ready within {0} seconds.", "{0}초 안에 준비되지 않았습니다.": "It wasn't ready within {0} seconds.",
"{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "Checks the source folder every {0} s and syncs the target (while the app is running).", "{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "Checks the source folder every {0} s and syncs the target (while the app is running).",
"· Android 의 yt-dlp 새 버전 확인 (다운로드 도구 안에서 따로 연결)": "· yt-dlp update check on Android (connects separately inside the download tools)", "· Android 의 yt-dlp 새 버전 확인 (다운로드 도구 안에서 따로 연결)": "· yt-dlp update check on Android (connects separately inside the download tools)",
@ -653,6 +659,7 @@
"감지한 언어: {0} (자동 감지)": "Detected language: {0} (auto-detect)", "감지한 언어: {0} (자동 감지)": "Detected language: {0} (auto-detect)",
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "Detected language: {0} - if it is wrong, make them again with another language", "감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "Detected language: {0} - if it is wrong, make them again with another language",
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "Downloaded only if the detected language differs from the subtitle language {0}", "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "Downloaded only if the detected language differs from the subtitle language {0}",
"값을 적어 주세요 (예: --exclude=*.tmp)": "Add a value (e.g. --exclude=*.tmp)",
"강하게 (0.4)": "Strong (0.4)", "강하게 (0.4)": "Strong (0.4)",
"강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "Stronger refines more but may drift from the original", "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "Stronger refines more but may drift from the original",
"같습니다 ✔": "Match ✔", "같습니다 ✔": "Match ✔",
@ -722,6 +729,7 @@
"경로 입력 · 네트워크 서버": "Enter path · Network servers", "경로 입력 · 네트워크 서버": "Enter path · Network servers",
"경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "The path is too long for the Recycle Bin (the Windows Recycle Bin does not accept paths over 260 characters).", "경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "The path is too long for the Recycle Bin (the Windows Recycle Bin does not accept paths over 260 characters).",
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "The path is too long (the Windows Recycle Bin doesn't accept paths over 260 characters) - you can delete it permanently", "경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "The path is too long (the Windows Recycle Bin doesn't accept paths over 260 characters) - you can delete it permanently",
"경로로 읽힙니다: {0}": "Read as a path: {0}",
"경로를 복사했습니다: {0}": "Copied path: {0}", "경로를 복사했습니다: {0}": "Copied path: {0}",
"계산 안 함": "Don't calculate", "계산 안 함": "Don't calculate",
"계속": "Always", "계속": "Always",
@ -746,6 +754,7 @@
"고치기": "Edit", "고치기": "Edit",
"고친 옵션을 저장하지 않았습니다": "The edited options aren't saved", "고친 옵션을 저장하지 않았습니다": "The edited options aren't saved",
"고화질": "High quality", "고화질": "High quality",
"곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "Retype with straight quotes and hyphens (-)",
"골라서 시작": "Choose and start", "골라서 시작": "Choose and start",
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "Cannot roll back: shared storage (Download) is not available.", "공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "Cannot roll back: shared storage (Download) is not available.",
"공용 폴더에 받은 파일 (동영상 · 그림 등)": "Files downloaded to shared folders (videos, images, etc.)", "공용 폴더에 받은 파일 (동영상 · 그림 등)": "Files downloaded to shared folders (videos, images, etc.)",
@ -815,6 +824,7 @@
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "To draw on this device, download the model first ({0})", "기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "To draw on this device, download the model first ({0})",
"기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "On some devices a list of apps opens instead - find JJ_MKVMaker in the list and turn it on.", "기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "On some devices a list of apps opens instead - find JJ_MKVMaker in the list and turn it on.",
"기능 버튼 줄": "Button bar", "기능 버튼 줄": "Button bar",
"기록 · 배치 파일은 전체 경로로 적어 주세요: {0}": "Please write the full path of the log or batch file: {0}",
"기본": "Default", "기본": "Default",
"기본 DNS": "Primary DNS", "기본 DNS": "Primary DNS",
"기본 앱으로 열기": "Open with default app", "기본 앱으로 열기": "Open with default app",
@ -1035,6 +1045,7 @@
"대상 폴더가 없습니다: {0}": "Target folder not found: {0}", "대상 폴더가 없습니다: {0}": "Target folder not found: {0}",
"대상에만 있는 {0}개를 지울지 확인이 필요합니다 (확인 전에는 지우지 않음)": "Confirm whether to delete {0} items that exist only in the target (nothing is deleted until you confirm)", "대상에만 있는 {0}개를 지울지 확인이 필요합니다 (확인 전에는 지우지 않음)": "Confirm whether to delete {0} items that exist only in the target (nothing is deleted until you confirm)",
"대상에서 {0}개를 지울까요?": "Delete {0} items from the target?", "대상에서 {0}개를 지울까요?": "Delete {0} items from the target?",
"대상에서 지울까요?": "Delete from the target?",
"대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "Files transferred to the target are deleted from the source (move)", "대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "Files transferred to the target are deleted from the source (move)",
"대상이 원본과 같거나 원본 안에 있습니다": "The target is the same as or inside the source", "대상이 원본과 같거나 원본 안에 있습니다": "The target is the same as or inside the source",
"대상이 원본과 같거나 원본 안에 있습니다 (맞출 때마다 원본이 바뀝니다). 다른 폴더를 고르세요.": "The target is the same as or inside the source (the source would change every sync). Choose another folder.", "대상이 원본과 같거나 원본 안에 있습니다 (맞출 때마다 원본이 바뀝니다). 다른 폴더를 고르세요.": "The target is the same as or inside the source (the source would change every sync). Choose another folder.",
@ -1124,6 +1135,8 @@
"등록 중 일부가 실패했습니다.": "Some registrations failed.", "등록 중 일부가 실패했습니다.": "Some registrations failed.",
"등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "Registered: \"Play with JJ_MKVMaker\" · \"Make subtitles with JJ_MKVMaker\" (Windows 11: Show more options)", "등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "Registered: \"Play with JJ_MKVMaker\" · \"Make subtitles with JJ_MKVMaker\" (Windows 11: Show more options)",
"따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "Not covered: the Tor screen (names are looked up inside Tor), IP addresses, home-network names (NAS / PC names without a dot, *.local, Tailscale *.ts.net etc. use the device DNS)", "따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "Not covered: the Tor screen (names are looked up inside Tor), IP addresses, home-network names (NAS / PC names without a dot, *.local, Tailscale *.ts.net etc. use the device DNS)",
"따옴표가 닫히지 않았습니다: {0}": "Unclosed quote: {0}",
"따옴표를 닫아 주세요": "Close the quote",
"또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "Or scan the QR code with the other device's camera (it includes the code)", "또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "Or scan the QR code with the other device's camera (it includes the code)",
"라오어": "Lao", "라오어": "Lao",
"라이선스 원문 보기": "View full license text", "라이선스 원문 보기": "View full license text",
@ -1139,6 +1152,7 @@
"링크 {0}개는 세지 않음": "{0} links not counted", "링크 {0}개는 세지 않음": "{0} links not counted",
"링크 · junction {0}개 (따라가지 않고 세지 않음)": "{0} links / junctions (not followed or counted)", "링크 · junction {0}개 (따라가지 않고 세지 않음)": "{0} links / junctions (not followed or counted)",
"링크 · junction 은 넣지 않습니다": "Links and junctions are not included", "링크 · junction 은 넣지 않습니다": "Links and junctions are not included",
"마그넷 · 토렌트가 시작되지 않으면 알리기": "Notify when a magnet / torrent doesn't start",
"마스터 · 최상 비밀번호": "Master and super passwords", "마스터 · 최상 비밀번호": "Master and super passwords",
"마스터 비밀번호": "Master password", "마스터 비밀번호": "Master password",
"마스터 비밀번호 넣기": "Enter master password", "마스터 비밀번호 넣기": "Enter master password",
@ -1194,10 +1208,12 @@
"먼저 mDNS 로 묻고 (이름을 알리는 NAS), 이어서 이 기기가 연결된 망 (Wi-Fi · 유선) 의 기기들에 SMB (445) 연결만 해 보고 바로 닫습니다. 파일 · 계정 정보는 보내지 않습니다.": "First asks via mDNS (NAS devices that announce their names), then tries an SMB (445) connection to devices on the network this device is on (Wi-Fi · wired) and closes it right away. No files or account details are sent.", "먼저 mDNS 로 묻고 (이름을 알리는 NAS), 이어서 이 기기가 연결된 망 (Wi-Fi · 유선) 의 기기들에 SMB (445) 연결만 해 보고 바로 닫습니다. 파일 · 계정 정보는 보내지 않습니다.": "First asks via mDNS (NAS devices that announce their names), then tries an SMB (445) connection to devices on the network this device is on (Wi-Fi · wired) and closes it right away. No files or account details are sent.",
"멈추고 옮기기": "Stop and move", "멈추고 옮기기": "Stop and move",
"멈춤": "Stopped", "멈춤": "Stopped",
"멈춤 (시작 안 됨)": "Stalled",
"멈춤 (이번 실행 동안)": "Pause (this session)", "멈춤 (이번 실행 동안)": "Pause (this session)",
"멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "Paused (this session) · press the start button to sync again", "멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "Paused (this session) · press the start button to sync again",
"멈춤 · {0}": "Stopped · {0}", "멈춤 · {0}": "Stopped · {0}",
"메모리가 모자라 Android 가 AI 엔진을 끝냈습니다. 다른 앱을 닫거나 환경 설정의 해상도 올리기 메모리를 \"적게\" 로 해 보세요": "Android stopped the AI engine because memory ran out. Close other apps or set the upscaling memory in Settings to \"Low\"", "메모리가 모자라 Android 가 AI 엔진을 끝냈습니다. 다른 앱을 닫거나 환경 설정의 해상도 올리기 메모리를 \"적게\" 로 해 보세요": "Android stopped the AI engine because memory ran out. Close other apps or set the upscaling memory in Settings to \"Low\"",
"메타데이터나 피어를 이 시간 동안 못 받으면 그 줄에 이유 후보와 [다시 시도] 를 보입니다": "If no metadata or peers arrive within this time, the row shows possible reasons and [Retry]",
"모니터링": "Monitor", "모니터링": "Monitor",
"모니터링의 [실행]": "[Run] in Monitoring", "모니터링의 [실행]": "[Run] in Monitoring",
"모델 (선택)": "Model (optional)", "모델 (선택)": "Model (optional)",
@ -1228,6 +1244,8 @@
"목록": "list", "목록": "list",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "Hides ad areas in lists · next to · on top of videos", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "Hides ad areas in lists · next to · on top of videos",
"목록 보기": "View list", "목록 보기": "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.", "목록에 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", "목록에서 동영상을 누르면 여기에 자세히 보입니다": "Tap a video in the list to see its details here",
"목록에서 빼기": "Remove from list", "목록에서 빼기": "Remove from list",
@ -1257,6 +1275,7 @@
"묻기 (기본)": "Ask (default)", "묻기 (기본)": "Ask (default)",
"물어보지 않기": "Never ask", "물어보지 않기": "Never ask",
"미리 보기": "Quick look", "미리 보기": "Quick look",
"미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다": "The preview shows nothing to delete - but because of the rules above, items may actually be deleted",
"미리 볼 수 없습니다: {0}": "Cannot preview: {0}", "미리 볼 수 없습니다: {0}": "Cannot preview: {0}",
"미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "Can't preview (the text isn't UTF-8)", "미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "Can't preview (the text isn't UTF-8)",
"미리 볼 수 없음 (글자 코드를 알 수 없습니다)": "Can't preview (unknown text encoding)", "미리 볼 수 없음 (글자 코드를 알 수 없습니다)": "Can't preview (unknown text encoding)",
@ -1345,6 +1364,7 @@
"밤 0~6시": "Night 0-6", "밤 0~6시": "Night 0-6",
"방법: {0}": "Method: {0}", "방법: {0}": "Method: {0}",
"방식": "Type", "방식": "Type",
"방화벽 · 공유기가 토렌트를 막음": "A firewall or router is blocking BitTorrent",
"방화벽 허용 앱 열기": "Open allowed apps", "방화벽 허용 앱 열기": "Open allowed apps",
"방화벽에서 이 앱을 허용합니다: 제어판 > Windows Defender 방화벽 > Windows Defender 방화벽을 통해 앱 또는 기능 허용 > [설정 변경] > JJ_MKVMaker 의 \"개인\" 에 체크 > [확인].": "Allow this app in the firewall: Control Panel > Windows Defender Firewall > Allow an app or feature through Windows Defender Firewall > [Change settings] > check \"Private\" for JJ_MKVMaker > [OK].", "방화벽에서 이 앱을 허용합니다: 제어판 > Windows Defender 방화벽 > Windows Defender 방화벽을 통해 앱 또는 기능 허용 > [설정 변경] > JJ_MKVMaker 의 \"개인\" 에 체크 > [확인].": "Allow this app in the firewall: Control Panel > Windows Defender Firewall > Allow an app or feature through Windows Defender Firewall > [Change settings] > check \"Private\" for JJ_MKVMaker > [OK].",
"배율": "Scale", "배율": "Scale",
@ -1551,6 +1571,7 @@
"서버가 그림을 돌려주지 않았습니다": "The server returned no image", "서버가 그림을 돌려주지 않았습니다": "The server returned no image",
"서버가 너무 오래 답하지 않습니다": "The server isn't responding for too long", "서버가 너무 오래 답하지 않습니다": "The server isn't responding for too long",
"서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "The server asks to move to another address ({0}). Check the server address.", "서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "The server asks to move to another address ({0}). Check the server address.",
"서버가 답하지 않습니다 ({0}초)": "The server is not responding ({0} s)",
"서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "The server sent an unexpected reply before starting encryption (FTPS). The connection was closed", "서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "The server sent an unexpected reply before starting encryption (FTPS). The connection was closed",
"서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "The server doesn't support encryption (FTPS) ({0} {1}). Turn on FTPS on the server, or choose \"Plain FTP\" knowing the risk", "서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "The server doesn't support encryption (FTPS) ({0} {1}). Turn on FTPS on the server, or choose \"Plain FTP\" knowing the risk",
"서버가 이 기능을 허용하지 않음": "the server does not allow this", "서버가 이 기능을 허용하지 않음": "the server does not allow this",
@ -1732,6 +1753,7 @@
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "{0} files inside are newer on the receiving side - they are skipped when merging (they stay in the source)", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "{0} files inside are newer on the receiving side - they are skipped when merging (they stay in the source)",
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "{0} files inside are newer on the receiving side - merging overwrites them with older ones", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "{0} files inside are newer on the receiving side - merging overwrites them with older ones",
"알고 실행합니다 (지울 목록이 실제와 다를 수 있음)": "I understand - run anyway (the delete list may differ from what actually happens)", "알고 실행합니다 (지울 목록이 실제와 다를 수 있음)": "I understand - run anyway (the delete list may differ from what actually happens)",
"알리지 않음": "Don't notify",
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "Notifications show as JJ_MKVMaker and open the app when clicked (current user only, no admin rights)", "알림이 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}", "암호 ZIP 으로 묶는 중 · {0}": "Making password ZIP · {0}",
"암호로 묶기": "Make password ZIP", "암호로 묶기": "Make password ZIP",
@ -1878,6 +1900,8 @@
"옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to move (tap, or mark with the circle on the right).", "옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to move (tap, or mark with the circle on the right).",
"옮길까요?": "move?", "옮길까요?": "move?",
"옵션": "Options", "옵션": "Options",
"옵션은 - 나 -- 로 시작합니다": "Options start with - or --",
"옵션을 알아볼 수 없습니다: {0} - {1}": "Unrecognized option: {0} - {1}",
"옵션을 저장했습니다": "Options saved", "옵션을 저장했습니다": "Options saved",
"완료": "Done", "완료": "Done",
"완료 시 자동 동영상 추가": "Auto-add videos when done", "완료 시 자동 동영상 추가": "Auto-add videos when done",
@ -1899,6 +1923,7 @@
"우르두어": "Urdu", "우르두어": "Urdu",
"우크라이나어": "Ukrainian", "우크라이나어": "Ukrainian",
"원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "Remote locations (WebDAV · SFTP · SMB · FTP) do not follow the filter options (--exclude, --include, --filter, …) - excluded items are copied too, and deleted if the run deletes. The comparison list above is what will actually happen.", "원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "Remote locations (WebDAV · SFTP · SMB · FTP) do not follow the filter options (--exclude, --include, --filter, …) - excluded items are copied too, and deleted if the run deletes. The comparison list above is what will actually happen.",
"원격 서버 응답 기다리기": "Wait for remote server response",
"원래 이름으로 되돌리지 못한 것: {0}": "Couldn't restore the original names of: {0}", "원래 이름으로 되돌리지 못한 것: {0}": "Couldn't restore the original names of: {0}",
"원래 자리에 같은 이름이 이미 있습니다": "An item with the same name already exists in the original place", "원래 자리에 같은 이름이 이미 있습니다": "An item with the same name already exists in the original place",
"원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "The original folder no longer exists, so it was not restored (restore it from the Recycle Bin yourself)", "원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "The original folder no longer exists, so it was not restored (restore it from the Recycle Bin yourself)",
@ -2042,6 +2067,7 @@
"이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "This connection is neither signed nor encrypted ({0}). Others on the same network could read or change the files, so it's best to turn on SMB signing or SMB3 encryption in the NAS settings", "이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "This connection is neither signed nor encrypted ({0}). Others on the same network could read or change the files, so it's best to turn on SMB signing or SMB3 encryption in the NAS settings",
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "This connection isn't encrypted ({0}). Others on the same network could see file contents, so it's best to turn on SMB3 encryption in the NAS settings", "이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "This connection isn't encrypted ({0}). Others on the same network could see file contents, so it's best to turn on SMB3 encryption in the NAS settings",
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}": "Some filter rules in these options can't be previewed - more may actually be deleted: {0}", "이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}": "Some filter rules in these options can't be previewed - more may actually be deleted: {0}",
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다": "Some filter rules in these options cannot be previewed, so nothing is deleted automatically",
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "This location (network drive, network share, USB memory etc.) has no Recycle Bin, so items are deleted right away.", "이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "This location (network drive, network share, USB memory etc.) has no Recycle Bin, so items are deleted right away.",
"이 이동 버튼 지우기": "Delete this move button", "이 이동 버튼 지우기": "Delete this move button",
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "These options will be used the next time this move runs. Run it now with the new options?", "이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "These options will be used the next time this move runs. Run it now with the new options?",
@ -2129,6 +2155,7 @@
"이번 업데이트로 바뀐 기본 설정": "Default settings changed by this update", "이번 업데이트로 바뀐 기본 설정": "Default settings changed by this update",
"이번에는 쓰지 않음 (번역 없음)": "Not used this time (no translation)", "이번에는 쓰지 않음 (번역 없음)": "Not used this time (no translation)",
"이어 받기": "Resume", "이어 받기": "Resume",
"이유 후보: {0}": "Possible reasons: {0}",
"이전": "Previous", "이전": "Previous",
"이전 (P)": "Previous (P)", "이전 (P)": "Previous (P)",
"이전 설정을 되살렸습니다": "Previous settings restored", "이전 설정을 되살렸습니다": "Previous settings restored",
@ -2139,6 +2166,7 @@
"인증서 확인 안 함": "Don't verify certificate", "인증서 확인 안 함": "Don't verify certificate",
"인증서: 공인 인증서면 그대로 연결, 자체 서명이면 [연결 확인] 때 지문을 보이고 묻습니다": "Certificate: a publicly trusted one connects directly; a self-signed one shows its fingerprint and asks at [Test connection]", "인증서: 공인 인증서면 그대로 연결, 자체 서명이면 [연결 확인] 때 지문을 보이고 묻습니다": "Certificate: a publicly trusted one connects directly; a self-signed one shows its fingerprint and asks at [Test connection]",
"인코딩": "Encoding", "인코딩": "Encoding",
"인터넷 IPv4 가 없음 (IPv6 만)": "No internet IPv4 (IPv6 only)",
"인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "Check your internet connection, then try again in Settings > Check required programs.\n\n{0}", "인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "Check your internet connection, then try again in Settings > Check required programs.\n\n{0}",
"인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "Check your internet connection or the server address in Settings > Network · DNS.", "인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "Check your internet connection or the server address in Settings > Network · DNS.",
"인터넷 자막 (OpenSubtitles.com)": "Internet subtitles (OpenSubtitles.com)", "인터넷 자막 (OpenSubtitles.com)": "Internet subtitles (OpenSubtitles.com)",
@ -2407,6 +2435,7 @@
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "Current version: v{0} → new version: v{1}", "지금 쓰는 버전: v{0} → 새 버전: v{1}": "Current version: v{0} → new version: v{1}",
"지금 작업 취소: {0}": "Cancelled current job: {0}", "지금 작업 취소: {0}": "Cancelled current job: {0}",
"지금 작업만 (대기 {0}개는 계속)": "Current job only ({0} waiting continue)", "지금 작업만 (대기 {0}개는 계속)": "Current job only ({0} waiting continue)",
"지금 지우기 (확인)": "Delete now (confirm)",
"지금 짝 추가": "Add current pair", "지금 짝 추가": "Add current pair",
"지금 짝을 즐겨찾기에": "Add the current pair to favorites", "지금 짝을 즐겨찾기에": "Add the current pair to favorites",
"지금 폴더": "Current folder", "지금 폴더": "Current folder",
@ -2419,8 +2448,10 @@
"지금까지 찾은 {0}개 모두": "All {0} found so far", "지금까지 찾은 {0}개 모두": "All {0} found so far",
"지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "This is the Dual App (cloned app). Android may not grant this permission to dual apps. If you can't turn it on, open the original JJ_MKVMaker icon without the badge.", "지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "This is the Dual App (cloned app). Android may not grant this permission to dual apps. If you can't turn it on, open the original JJ_MKVMaker icon without the badge.",
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "The last run did not end normally (last check {0}:{1}). It has been noted in the job log.", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {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", "지난 작업 기록": "Old job logs",
"지문 확인하고 연결": "Check fingerprint and connect", "지문 확인하고 연결": "Check fingerprint and connect",
"지우고 맞추기": "Delete and sync",
"지우고 초기화": "Delete and reset", "지우고 초기화": "Delete and reset",
"지우기": "Delete", "지우기": "Delete",
"지우기 포함": "with delete", "지우기 포함": "with delete",
@ -2575,6 +2606,9 @@
"큰 파일": "Large files", "큰 파일": "Large files",
"큰 파일이 없습니다": "No large files", "큰 파일이 없습니다": "No large files",
"큰 폴더": "Large folders", "큰 폴더": "Large folders",
"큰따옴표 (\") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}": "Options containing a double quote (\") cannot be passed to rsync on Windows: {0}",
"큰따옴표 (\") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}": "Arguments containing a double quote (\") cannot be passed as-is to rsync on Windows, so it was not run: {0}",
"큰따옴표 (\") 를 빼 주세요": "Remove the double quote (\")",
"키 있음": "Key saved", "키 있음": "Key saved",
"타갈로그어": "Tagalog", "타갈로그어": "Tagalog",
"타밀어": "Tamil", "타밀어": "Tamil",
@ -2591,6 +2625,7 @@
"토": "Sat", "토": "Sat",
"토렌트 · 마그넷 다운로드": "Torrent · magnet download", "토렌트 · 마그넷 다운로드": "Torrent · magnet download",
"통과": "Passed", "통과": "Passed",
"트래커 · 피어에 닿지 않습니다 ({0}초째)": "Can't reach trackers or peers ({0} s)",
"트랙 #{0} ({1}){2}": "Track #{0} ({1}){2}", "트랙 #{0} ({1}){2}": "Track #{0} ({1}){2}",
"트랙 {0}": "Track {0}", "트랙 {0}": "Track {0}",
"트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "Reopen with the tray icon or the hotkey below", "트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "Reopen with the tray icon or the hotkey below",
@ -2697,6 +2732,7 @@
"표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "You can edit display names right here. Pressing a video row three times moves it to the first button's folder", "표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "You can edit display names right here. Pressing a video row three times moves it to the first button's folder",
"표시 지우기": "Clear marks", "표시 지우기": "Clear marks",
"표준": "Standard", "표준": "Standard",
"표준 입력 (-) 은 쓸 수 없습니다": "Standard input (-) cannot be used",
"푸는 중": "Extracting", "푸는 중": "Extracting",
"푸는 중: {0}": "Extracting: {0}", "푸는 중: {0}": "Extracting: {0}",
"풀 수 없는 이름 {0}개": "{0} invalid names", "풀 수 없는 이름 {0}개": "{0} invalid names",

View File

@ -73,6 +73,7 @@
"- 감지한 언어": "- 検出した言語", "- 감지한 언어": "- 検出した言語",
"-1 은 아무렇게나": "-1 はランダム", "-1 은 아무렇게나": "-1 はランダム",
".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)", ".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)",
"/ 로 시작하는 전체 경로로": "/ で始まるフルパスで",
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 制限なし。すべての方法に適用: rsync は --bwlimit、現在の方式はアプリが調整、robocopy は /IPG で近似。オプション欄に直接入れても構いません (そちらが優先)", "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 制限なし。すべての方法に適用: rsync は --bwlimit、現在の方式はアプリが調整、robocopy は /IPG で近似。オプション欄に直接入れても構いません (そちらが優先)",
"0 으로": "0 に戻す", "0 으로": "0 に戻す",
"1/2 · AI 그림으로 다듬는 중": "1/2 · AI 画像で整え中", "1/2 · AI 그림으로 다듬는 중": "1/2 · AI 画像で整え中",
@ -153,6 +154,7 @@
"Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "Android 自動バックアップから以前の設定を復元しました (動画リスト {0} 件・WebDAV サーバー {1} 件)", "Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "Android 自動バックアップから以前の設定を復元しました (動画リスト {0} 件・WebDAV サーバー {1} 件)",
"Android: Tor 화면이 열려 있는 동안": "Android: Tor 画面を開いている間", "Android: Tor 화면이 열려 있는 동안": "Android: Tor 画面を開いている間",
"BPE 토크나이저가 아닙니다: {0}": "BPE トークナイザーではありません: {0}", "BPE 토크나이저가 아닙니다: {0}": "BPE トークナイザーではありません: {0}",
"C:\\ 처럼 드라이브로 시작하는 전체 경로로": "C:\\ のようにドライブで始まるフルパスで",
"Chrome (내려받기)": "Chrome(ダウンロード)", "Chrome (내려받기)": "Chrome(ダウンロード)",
"Chrome (내장)": "Chrome(内蔵)", "Chrome (내장)": "Chrome(内蔵)",
"Chrome 에서 가져오기": "Chrome から取り込む", "Chrome 에서 가져오기": "Chrome から取り込む",
@ -166,6 +168,7 @@
"Chrome 엔진을 시작하지 못했습니다": "Chrome エンジンを起動できませんでした", "Chrome 엔진을 시작하지 못했습니다": "Chrome エンジンを起動できませんでした",
"Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "Chrome エンジンを起動できません。環境設定で Edge に切り替えてください。\n{0}", "Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "Chrome エンジンを起動できません。環境設定で Edge に切り替えてください。\n{0}",
"Chrome 엔진의 연결 통로를 다시 열 수 없습니다. 프로그램을 다시 시작하세요.": "Chrome エンジンの接続経路を開き直せません。アプリを再起動してください。", "Chrome 엔진의 연결 통로를 다시 열 수 없습니다. 프로그램을 다시 시작하세요.": "Chrome エンジンの接続経路を開き直せません。アプリを再起動してください。",
"DNS 강제 (환경 설정 > 네트워크 · DNS)": "DNS 強制 (設定 > ネットワーク・DNS)",
"DNS 강제 프록시 시작: 127.0.0.1:{0} ({1})": "DNS 強制プロキシ開始: 127.0.0.1:{0} ({1})", "DNS 강제 프록시 시작: 127.0.0.1:{0} ({1})": "DNS 強制プロキシ開始: 127.0.0.1:{0} ({1})",
"DNS 강제 프록시: 비밀값이 없는 연결을 거절 ({0})": "DNS 強制プロキシ: 秘密値のない接続を拒否 ({0})", "DNS 강제 프록시: 비밀값이 없는 연결을 거절 ({0})": "DNS 強制プロキシ: 秘密値のない接続を拒否 ({0})",
"DNS 강제 프록시: 비밀값이 틀린 연결을 거절 ({0})": "DNS 強制プロキシ: 秘密値が違う接続を拒否 ({0})", "DNS 강제 프록시: 비밀값이 틀린 연결을 거절 ({0})": "DNS 強制プロキシ: 秘密値が違う接続を拒否 ({0})",
@ -355,6 +358,7 @@
"VLC 로 열기": "VLC で開く", "VLC 로 열기": "VLC で開く",
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失敗: {1} {2}{3}", "WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失敗: {1} {2}{3}",
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "WebDAV・OpenSubtitles のパスワードをこの端末の安全な保存領域に保存します。オフにすると保存せず (既存のものも削除)、アプリを起動するたびに入力します", "WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "WebDAV・OpenSubtitles のパスワードをこの端末の安全な保存領域に保存します。オフにすると保存せず (既存のものも削除)、アプリを起動するたびに入力します",
"WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)": "WebDAV · SFTP · FTP · SMB でダウンロード・アップロード中に、この時間何もやり取りがなければ失敗として知らせます (サーバーが止まってもずっと待たないように)",
"WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV・SFTP・SMB・FTP・OpenSubtitles のパスワード、SFTP キー、API キー。サーバー一覧と ID は残します", "WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV・SFTP・SMB・FTP・OpenSubtitles のパスワード、SFTP キー、API キー。サーバー一覧と ID は残します",
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).": "WebDAV・SFTP・SMB・FTP: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files・フィルター (--exclude・--include・--filter・--delete-excluded) に従う)。", "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).": "WebDAV・SFTP・SMB・FTP: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files・フィルター (--exclude・--include・--filter・--delete-excluded) に従う)。",
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV・SFTP・SMB・FTP: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files に従う)。", "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV・SFTP・SMB・FTP: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files に従う)。",
@ -524,6 +528,7 @@
"{0} 을(를) 설치했습니다.": "{0} をインストールしました。", "{0} 을(를) 설치했습니다.": "{0} をインストールしました。",
"{0} 을(를) 지울까요?": "{0} を削除しますか?", "{0} 을(를) 지울까요?": "{0} を削除しますか?",
"{0} 의 SHA256 이 다릅니다 (손상되었거나 다른 파일). 설치를 멈췄습니다.": "{0} の SHA256 が一致しません (破損しているか別のファイル)。インストールを中止しました。", "{0} 의 SHA256 이 다릅니다 (손상되었거나 다른 파일). 설치를 멈췄습니다.": "{0} の SHA256 が一致しません (破損しているか別のファイル)。インストールを中止しました。",
"{0} 의 값이 없습니다": "{0} の値がありません",
"{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} は空です (カード・USB・ディスクを入れてからもう一度押してください)。", "{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} は空です (カード・USB・ディスクを入れてからもう一度押してください)。",
"{0} 이상": "{0} 以上", "{0} 이상": "{0} 以上",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。",
@ -609,6 +614,7 @@
"{0}장을 올려 저장했습니다{1}": "{0} ページを高解像度化して保存しました{1}", "{0}장을 올려 저장했습니다{1}": "{0} ページを高解像度化して保存しました{1}",
"{0}줄": "{0}行", "{0}줄": "{0}行",
"{0}초": "{0}秒", "{0}초": "{0}秒",
"{0}초 (기본)": "{0}秒 (既定)",
"{0}초 안에 준비되지 않았습니다.": "{0} 秒以内に準備できませんでした。", "{0}초 안에 준비되지 않았습니다.": "{0} 秒以内に準備できませんでした。",
"{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "{0} 秒ごとに元フォルダーを確認して対象フォルダーに合わせます (アプリの起動中)。", "{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "{0} 秒ごとに元フォルダーを確認して対象フォルダーに合わせます (アプリの起動中)。",
"· Android 의 yt-dlp 새 버전 확인 (다운로드 도구 안에서 따로 연결)": "· Android の yt-dlp 新バージョン確認 (ダウンロードツール内で別に接続)", "· Android 의 yt-dlp 새 버전 확인 (다운로드 도구 안에서 따로 연결)": "· Android の yt-dlp 新バージョン確認 (ダウンロードツール内で別に接続)",
@ -653,6 +659,7 @@
"감지한 언어: {0} (자동 감지)": "検出した言語: {0} (自動検出)", "감지한 언어: {0} (자동 감지)": "検出した言語: {0} (自動検出)",
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "検出した言語: {0} - 違っていれば別の言語で作り直してください", "감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "検出した言語: {0} - 違っていれば別の言語で作り直してください",
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "検出した言語が作成する言語と異なる場合のみダウンロード {0}", "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "検出した言語が作成する言語と異なる場合のみダウンロード {0}",
"값을 적어 주세요 (예: --exclude=*.tmp)": "値を入力してください (例: --exclude=*.tmp)",
"강하게 (0.4)": "強め (0.4)", "강하게 (0.4)": "強め (0.4)",
"강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "強いほど大きく整えますが、元と違ってくることがあります", "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "強いほど大きく整えますが、元と違ってくることがあります",
"같습니다 ✔": "一致します ✔", "같습니다 ✔": "一致します ✔",
@ -722,6 +729,7 @@
"경로 입력 · 네트워크 서버": "パス入力・ネットワークサーバー", "경로 입력 · 네트워크 서버": "パス入力・ネットワークサーバー",
"경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "パスが長すぎてごみ箱に入れられません (Windows のごみ箱は 260 文字を超えるパスを受け付けません)。", "경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "パスが長すぎてごみ箱に入れられません (Windows のごみ箱は 260 文字を超えるパスを受け付けません)。",
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "パスが長すぎます (Windows のごみ箱は 260 文字を超えるパスを受け付けません) - 完全に削除はできます", "경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "パスが長すぎます (Windows のごみ箱は 260 文字を超えるパスを受け付けません) - 完全に削除はできます",
"경로로 읽힙니다: {0}": "パスとして読まれます: {0}",
"경로를 복사했습니다: {0}": "パスをコピーしました: {0}", "경로를 복사했습니다: {0}": "パスをコピーしました: {0}",
"계산 안 함": "計算しない", "계산 안 함": "計算しない",
"계속": "常時", "계속": "常時",
@ -746,6 +754,7 @@
"고치기": "編集", "고치기": "編集",
"고친 옵션을 저장하지 않았습니다": "編集したオプションが保存されていません", "고친 옵션을 저장하지 않았습니다": "編集したオプションが保存されていません",
"고화질": "高画質", "고화질": "高画質",
"곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "半角の引用符とハイフン (-) で入力し直してください",
"골라서 시작": "選んで開始", "골라서 시작": "選んで開始",
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "共有ストレージ (Download) を使えないため戻せません。", "공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "共有ストレージ (Download) を使えないため戻せません。",
"공용 폴더에 받은 파일 (동영상 · 그림 등)": "共有フォルダーにダウンロードしたファイル (動画・画像など)", "공용 폴더에 받은 파일 (동영상 · 그림 등)": "共有フォルダーにダウンロードしたファイル (動画・画像など)",
@ -815,6 +824,7 @@
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "この端末で描くにはモデルのダウンロードが必要です ({0})", "기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "この端末で描くにはモデルのダウンロードが必要です ({0})",
"기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "機器によってはアプリ一覧が開きます。その場合は一覧から JJ_MKVMaker を探してオンにしてください。", "기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "機器によってはアプリ一覧が開きます。その場合は一覧から JJ_MKVMaker を探してオンにしてください。",
"기능 버튼 줄": "機能ボタンの列", "기능 버튼 줄": "機能ボタンの列",
"기록 · 배치 파일은 전체 경로로 적어 주세요: {0}": "ログ・バッチファイルはフルパスで書いてください: {0}",
"기본": "既定", "기본": "既定",
"기본 DNS": "優先 DNS", "기본 DNS": "優先 DNS",
"기본 앱으로 열기": "既定のアプリで開く", "기본 앱으로 열기": "既定のアプリで開く",
@ -1035,6 +1045,7 @@
"대상 폴더가 없습니다: {0}": "対象フォルダーがありません: {0}", "대상 폴더가 없습니다: {0}": "対象フォルダーがありません: {0}",
"대상에만 있는 {0}개를 지울지 확인이 필요합니다 (확인 전에는 지우지 않음)": "対象にだけある {0} 個を削除するか確認が必要です (確認するまで削除しません)", "대상에만 있는 {0}개를 지울지 확인이 필요합니다 (확인 전에는 지우지 않음)": "対象にだけある {0} 個を削除するか確認が必要です (確認するまで削除しません)",
"대상에서 {0}개를 지울까요?": "対象から {0} 個を削除しますか?", "대상에서 {0}개를 지울까요?": "対象から {0} 個を削除しますか?",
"대상에서 지울까요?": "対象から削除しますか?",
"대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "対象へ移したファイルを元から削除します (移動)", "대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "対象へ移したファイルを元から削除します (移動)",
"대상이 원본과 같거나 원본 안에 있습니다": "対象が元と同じか、元の中にあります", "대상이 원본과 같거나 원본 안에 있습니다": "対象が元と同じか、元の中にあります",
"대상이 원본과 같거나 원본 안에 있습니다 (맞출 때마다 원본이 바뀝니다). 다른 폴더를 고르세요.": "対象が元と同じか元の中にあります (同期するたびに元が変わります)。別のフォルダーを選んでください。", "대상이 원본과 같거나 원본 안에 있습니다 (맞출 때마다 원본이 바뀝니다). 다른 폴더를 고르세요.": "対象が元と同じか元の中にあります (同期するたびに元が変わります)。別のフォルダーを選んでください。",
@ -1124,6 +1135,8 @@
"등록 중 일부가 실패했습니다.": "登録の一部に失敗しました。", "등록 중 일부가 실패했습니다.": "登録の一部に失敗しました。",
"등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "登録済み:「JJ_MKVMaker で再生」・「JJ_MKVMaker で字幕を作成」(Windows 11: その他のオプションを表示)", "등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "登録済み:「JJ_MKVMaker で再生」・「JJ_MKVMaker で字幕を作成」(Windows 11: その他のオプションを表示)",
"따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "対象外: Tor 画面 (名前も Tor 内で解決)、IP アドレス、家庭内ネットワークの名前 (ドットのない NAS・PC 名、*.local、Tailscale の *.ts.net などは端末の DNS)", "따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "対象外: Tor 画面 (名前も Tor 内で解決)、IP アドレス、家庭内ネットワークの名前 (ドットのない NAS・PC 名、*.local、Tailscale の *.ts.net などは端末の DNS)",
"따옴표가 닫히지 않았습니다: {0}": "引用符が閉じていません: {0}",
"따옴표를 닫아 주세요": "引用符を閉じてください",
"또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "または、もう一方の端末のカメラで QR コードを読み取ってください (コード入り)", "또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "または、もう一方の端末のカメラで QR コードを読み取ってください (コード入り)",
"라오어": "ラオ語", "라오어": "ラオ語",
"라이선스 원문 보기": "ライセンス原文を見る", "라이선스 원문 보기": "ライセンス原文を見る",
@ -1139,6 +1152,7 @@
"링크 {0}개는 세지 않음": "リンク {0} 件は数えず", "링크 {0}개는 세지 않음": "リンク {0} 件は数えず",
"링크 · junction {0}개 (따라가지 않고 세지 않음)": "リンク・ジャンクション {0} 個 (たどらず数えません)", "링크 · junction {0}개 (따라가지 않고 세지 않음)": "リンク・ジャンクション {0} 個 (たどらず数えません)",
"링크 · junction 은 넣지 않습니다": "リンク・ジャンクションは含めません", "링크 · junction 은 넣지 않습니다": "リンク・ジャンクションは含めません",
"마그넷 · 토렌트가 시작되지 않으면 알리기": "マグネット・トレントが始まらないときに知らせる",
"마스터 · 최상 비밀번호": "マスター / 最上位パスワード", "마스터 · 최상 비밀번호": "マスター / 最上位パスワード",
"마스터 비밀번호": "マスターパスワード", "마스터 비밀번호": "マスターパスワード",
"마스터 비밀번호 넣기": "マスターパスワードを入力", "마스터 비밀번호 넣기": "マスターパスワードを入力",
@ -1194,10 +1208,12 @@
"먼저 mDNS 로 묻고 (이름을 알리는 NAS), 이어서 이 기기가 연결된 망 (Wi-Fi · 유선) 의 기기들에 SMB (445) 연결만 해 보고 바로 닫습니다. 파일 · 계정 정보는 보내지 않습니다.": "まず mDNS で問い合わせ (名前を知らせる NAS)、続いてこの端末が接続しているネットワーク (Wi-Fi · 有線) の機器に SMB (445) で接続だけ試し、すぐに閉じます。ファイルやアカウント情報は送りません。", "먼저 mDNS 로 묻고 (이름을 알리는 NAS), 이어서 이 기기가 연결된 망 (Wi-Fi · 유선) 의 기기들에 SMB (445) 연결만 해 보고 바로 닫습니다. 파일 · 계정 정보는 보내지 않습니다.": "まず mDNS で問い合わせ (名前を知らせる NAS)、続いてこの端末が接続しているネットワーク (Wi-Fi · 有線) の機器に SMB (445) で接続だけ試し、すぐに閉じます。ファイルやアカウント情報は送りません。",
"멈추고 옮기기": "止めて移す", "멈추고 옮기기": "止めて移す",
"멈춤": "停止", "멈춤": "停止",
"멈춤 (시작 안 됨)": "停滞 (開始されません)",
"멈춤 (이번 실행 동안)": "停止 (今回の実行中)", "멈춤 (이번 실행 동안)": "停止 (今回の実行中)",
"멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "停止中 (今回の実行中) · 開始ボタンで再び同期します", "멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "停止中 (今回の実行中) · 開始ボタンで再び同期します",
"멈춤 · {0}": "停止 · {0}", "멈춤 · {0}": "停止 · {0}",
"메모리가 모자라 Android 가 AI 엔진을 끝냈습니다. 다른 앱을 닫거나 환경 설정의 해상도 올리기 메모리를 \"적게\" 로 해 보세요": "メモリ不足のため Android が AI エンジンを終了しました。ほかのアプリを閉じるか、設定の解像度アップのメモリを「少なめ」にしてください", "메모리가 모자라 Android 가 AI 엔진을 끝냈습니다. 다른 앱을 닫거나 환경 설정의 해상도 올리기 메모리를 \"적게\" 로 해 보세요": "メモリ不足のため Android が AI エンジンを終了しました。ほかのアプリを閉じるか、設定の解像度アップのメモリを「少なめ」にしてください",
"메타데이터나 피어를 이 시간 동안 못 받으면 그 줄에 이유 후보와 [다시 시도] 를 보입니다": "この時間内にメタデータやピアを受け取れないと、その行に考えられる理由と [再試行] を表示します",
"모니터링": "モニタリング", "모니터링": "モニタリング",
"모니터링의 [실행]": "モニタリングの [実行]", "모니터링의 [실행]": "モニタリングの [実行]",
"모델 (선택)": "モデル (任意)", "모델 (선택)": "モデル (任意)",
@ -1228,6 +1244,8 @@
"목록": "一覧", "목록": "一覧",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "リスト・動画の横・動画の上に出る広告エリアを隠します", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "リスト・動画の横・動画の上に出る広告エリアを隠します",
"목록 보기": "リストを見る", "목록 보기": "リストを見る",
"목록 파일은 전체 경로로 적어 주세요: {0}": "リストファイルはフルパスで書いてください: {0}",
"목록 파일의 전체 경로를 적어 주세요": "リストファイルのフルパスを書いてください",
"목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "一覧に JJ_MKVMaker がなければ [別のアプリの許可] で JJ_MKVMaker.exe を選び「プライベート」にチェックします。変更後は共有をオフにして再びオンにしてください。", "목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "一覧に JJ_MKVMaker がなければ [別のアプリの許可] で JJ_MKVMaker.exe を選び「プライベート」にチェックします。変更後は共有をオフにして再びオンにしてください。",
"목록에서 동영상을 누르면 여기에 자세히 보입니다": "リストの動画をタップすると、ここに詳細が表示されます", "목록에서 동영상을 누르면 여기에 자세히 보입니다": "リストの動画をタップすると、ここに詳細が表示されます",
"목록에서 빼기": "リストから外す", "목록에서 빼기": "リストから外す",
@ -1257,6 +1275,7 @@
"묻기 (기본)": "確認する (既定)", "묻기 (기본)": "確認する (既定)",
"물어보지 않기": "尋ねない", "물어보지 않기": "尋ねない",
"미리 보기": "クイックプレビュー", "미리 보기": "クイックプレビュー",
"미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다": "プレビューでは削除するものはありません - ただし上のルールのため実際には削除されることがあります",
"미리 볼 수 없습니다: {0}": "プレビューできません: {0}", "미리 볼 수 없습니다: {0}": "プレビューできません: {0}",
"미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "プレビューできません (文字コードが UTF-8 ではありません)", "미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "プレビューできません (文字コードが UTF-8 ではありません)",
"미리 볼 수 없음 (글자 코드를 알 수 없습니다)": "プレビューできません (文字コードが分かりません)", "미리 볼 수 없음 (글자 코드를 알 수 없습니다)": "プレビューできません (文字コードが分かりません)",
@ -1345,6 +1364,7 @@
"밤 0~6시": "夜 0~6 時", "밤 0~6시": "夜 0~6 時",
"방법: {0}": "方法: {0}", "방법: {0}": "方法: {0}",
"방식": "方式", "방식": "方式",
"방화벽 · 공유기가 토렌트를 막음": "ファイアウォール・ルーターがトレントを遮断",
"방화벽 허용 앱 열기": "許可されたアプリを開く", "방화벽 허용 앱 열기": "許可されたアプリを開く",
"방화벽에서 이 앱을 허용합니다: 제어판 > Windows Defender 방화벽 > Windows Defender 방화벽을 통해 앱 또는 기능 허용 > [설정 변경] > JJ_MKVMaker 의 \"개인\" 에 체크 > [확인].": "ファイアウォールでこのアプリを許可します: コントロール パネル > Windows Defender ファイアウォール > Windows Defender ファイアウォールを介したアプリまたは機能を許可 > [設定の変更] > JJ_MKVMaker の「プライベート」にチェック > [OK]。", "방화벽에서 이 앱을 허용합니다: 제어판 > Windows Defender 방화벽 > Windows Defender 방화벽을 통해 앱 또는 기능 허용 > [설정 변경] > JJ_MKVMaker 의 \"개인\" 에 체크 > [확인].": "ファイアウォールでこのアプリを許可します: コントロール パネル > Windows Defender ファイアウォール > Windows Defender ファイアウォールを介したアプリまたは機能を許可 > [設定の変更] > JJ_MKVMaker の「プライベート」にチェック > [OK]。",
"배율": "倍率", "배율": "倍率",
@ -1551,6 +1571,7 @@
"서버가 그림을 돌려주지 않았습니다": "サーバーが画像を返しませんでした", "서버가 그림을 돌려주지 않았습니다": "サーバーが画像を返しませんでした",
"서버가 너무 오래 답하지 않습니다": "サーバーの応答が長時間ありません", "서버가 너무 오래 답하지 않습니다": "サーバーの応答が長時間ありません",
"서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "サーバーが別のアドレスへの移動を求めています ({0})。サーバー設定のアドレスを確認してください。", "서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "サーバーが別のアドレスへの移動を求めています ({0})。サーバー設定のアドレスを確認してください。",
"서버가 답하지 않습니다 ({0}초)": "サーバーが応答しません ({0} 秒)",
"서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "サーバーが暗号化 (FTPS) の開始前に予期しない応答を送りました。接続を切りました", "서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "サーバーが暗号化 (FTPS) の開始前に予期しない応答を送りました。接続を切りました",
"서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "サーバーが暗号化 (FTPS) に対応していません ({0} {1})。サーバー側で FTPS を有効にするか、リスクを理解した上で「平文 FTP」を選んでください", "서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "サーバーが暗号化 (FTPS) に対応していません ({0} {1})。サーバー側で FTPS を有効にするか、リスクを理解した上で「平文 FTP」を選んでください",
"서버가 이 기능을 허용하지 않음": "サーバーがこの機能を許可していません", "서버가 이 기능을 허용하지 않음": "サーバーがこの機能を許可していません",
@ -1732,6 +1753,7 @@
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせてもスキップします (元に残ります)", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせてもスキップします (元に残ります)",
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします",
"알고 실행합니다 (지울 목록이 실제와 다를 수 있음)": "理解した上で実行します (削除リストが実際と異なる場合があります)", "알고 실행합니다 (지울 목록이 실제와 다를 수 있음)": "理解した上で実行します (削除リストが実際と異なる場合があります)",
"알리지 않음": "知らせない",
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)",
"암호 ZIP 으로 묶는 중 · {0}": "パスワード付き ZIP にまとめています · {0}", "암호 ZIP 으로 묶는 중 · {0}": "パスワード付き ZIP にまとめています · {0}",
"암호로 묶기": "パスワード付きでまとめる", "암호로 묶기": "パスワード付きでまとめる",
@ -1878,6 +1900,8 @@
"옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "移動する項目を選んでください (タップするか右の丸で印を付ける)。", "옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "移動する項目を選んでください (タップするか右の丸で印を付ける)。",
"옮길까요?": "移動しますか?", "옮길까요?": "移動しますか?",
"옵션": "オプション", "옵션": "オプション",
"옵션은 - 나 -- 로 시작합니다": "オプションは - か -- で始まります",
"옵션을 알아볼 수 없습니다: {0} - {1}": "オプションを認識できません: {0} - {1}",
"옵션을 저장했습니다": "オプションを保存しました", "옵션을 저장했습니다": "オプションを保存しました",
"완료": "完了", "완료": "完了",
"완료 시 자동 동영상 추가": "完了時に動画を自動追加", "완료 시 자동 동영상 추가": "完了時に動画を自動追加",
@ -1899,6 +1923,7 @@
"우르두어": "ウルドゥー語", "우르두어": "ウルドゥー語",
"우크라이나어": "ウクライナ語", "우크라이나어": "ウクライナ語",
"원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "リモート (WebDAV・SFTP・SMB・FTP) はオプションのフィルター (--exclude・--include・--filter など) に従いません - 除外したものもコピーし、削除を含む場合は削除します。上の比較一覧が実際に行う内容です。", "원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "リモート (WebDAV・SFTP・SMB・FTP) はオプションのフィルター (--exclude・--include・--filter など) に従いません - 除外したものもコピーし、削除を含む場合は削除します。上の比較一覧が実際に行う内容です。",
"원격 서버 응답 기다리기": "リモートサーバーの応答待ち",
"원래 이름으로 되돌리지 못한 것: {0}": "元の名前に戻せなかったもの: {0}", "원래 이름으로 되돌리지 못한 것: {0}": "元の名前に戻せなかったもの: {0}",
"원래 자리에 같은 이름이 이미 있습니다": "元の場所に同じ名前が既にあります", "원래 자리에 같은 이름이 이미 있습니다": "元の場所に同じ名前が既にあります",
"원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "元のフォルダーがないため元に戻せませんでした (ごみ箱から直接復元してください)", "원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "元のフォルダーがないため元に戻せませんでした (ごみ箱から直接復元してください)",
@ -2042,6 +2067,7 @@
"이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "この接続は署名も暗号化もされません ({0})。同じネットワークの他の人がファイルの内容を見たり変えたりできるため、NAS の設定で SMB 署名か SMB3 暗号化をオンにすることをおすすめします", "이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "この接続は署名も暗号化もされません ({0})。同じネットワークの他の人がファイルの内容を見たり変えたりできるため、NAS の設定で SMB 署名か SMB3 暗号化をオンにすることをおすすめします",
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "この接続は暗号化されていません ({0})。同じネットワークの他の人がファイルの内容を見られるため、NAS の設定で SMB3 暗号化をオンにすることをお勧めします", "이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "この接続は暗号化されていません ({0})。同じネットワークの他の人がファイルの内容を見られるため、NAS の設定で SMB3 暗号化をオンにすることをお勧めします",
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}": "このオプションの一部のフィルター規則はプレビューできません - 実際にはさらに削除される場合があります: {0}", "이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}": "このオプションの一部のフィルター規則はプレビューできません - 実際にはさらに削除される場合があります: {0}",
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다": "このオプションの一部のフィルタールールはプレビューできないため、自動では削除しません",
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "この場所 (ネットワークドライブ・ネットワーク共有・USB メモリなど) にはごみ箱がないため、すぐに削除されます。", "이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "この場所 (ネットワークドライブ・ネットワーク共有・USB メモリなど) にはごみ箱がないため、すぐに削除されます。",
"이 이동 버튼 지우기": "この移動ボタンを削除", "이 이동 버튼 지우기": "この移動ボタンを削除",
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "この移動を次に行うときにこのオプションを使います。今すぐ新しいオプションで実行しますか?", "이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "この移動を次に行うときにこのオプションを使います。今すぐ新しいオプションで実行しますか?",
@ -2129,6 +2155,7 @@
"이번 업데이트로 바뀐 기본 설정": "今回のアップデートで変わった既定の設定", "이번 업데이트로 바뀐 기본 설정": "今回のアップデートで変わった既定の設定",
"이번에는 쓰지 않음 (번역 없음)": "今回は使用しません (翻訳なし)", "이번에는 쓰지 않음 (번역 없음)": "今回は使用しません (翻訳なし)",
"이어 받기": "続きをダウンロード", "이어 받기": "続きをダウンロード",
"이유 후보: {0}": "考えられる理由: {0}",
"이전": "前へ", "이전": "前へ",
"이전 (P)": "前へ(P)", "이전 (P)": "前へ(P)",
"이전 설정을 되살렸습니다": "以前の設定を復元しました", "이전 설정을 되살렸습니다": "以前の設定を復元しました",
@ -2139,6 +2166,7 @@
"인증서 확인 안 함": "証明書を検証しない", "인증서 확인 안 함": "証明書を検証しない",
"인증서: 공인 인증서면 그대로 연결, 자체 서명이면 [연결 확인] 때 지문을 보이고 묻습니다": "証明書: 公的な証明書ならそのまま接続し、自己署名なら [接続確認] で指紋を表示して確認します", "인증서: 공인 인증서면 그대로 연결, 자체 서명이면 [연결 확인] 때 지문을 보이고 묻습니다": "証明書: 公的な証明書ならそのまま接続し、自己署名なら [接続確認] で指紋を表示して確認します",
"인코딩": "エンコード", "인코딩": "エンコード",
"인터넷 IPv4 가 없음 (IPv6 만)": "インターネット IPv4 がない (IPv6 のみ)",
"인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "インターネット接続を確認してから、環境設定 > 必須プログラムの点検 でもう一度試してください。\n\n{0}", "인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "インターネット接続を確認してから、環境設定 > 必須プログラムの点検 でもう一度試してください。\n\n{0}",
"인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "インターネット接続、または 設定 > ネットワーク · DNS のサーバーアドレスを確認してください。", "인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "インターネット接続、または 設定 > ネットワーク · DNS のサーバーアドレスを確認してください。",
"인터넷 자막 (OpenSubtitles.com)": "インターネット字幕(OpenSubtitles.com)", "인터넷 자막 (OpenSubtitles.com)": "インターネット字幕(OpenSubtitles.com)",
@ -2407,6 +2435,7 @@
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "現在のバージョン: v{0} → 新しいバージョン: v{1}", "지금 쓰는 버전: v{0} → 새 버전: v{1}": "現在のバージョン: v{0} → 新しいバージョン: v{1}",
"지금 작업 취소: {0}": "現在の作業をキャンセル: {0}", "지금 작업 취소: {0}": "現在の作業をキャンセル: {0}",
"지금 작업만 (대기 {0}개는 계속)": "現在の作業だけ (待機 {0} 件は続行)", "지금 작업만 (대기 {0}개는 계속)": "現在の作業だけ (待機 {0} 件は続行)",
"지금 지우기 (확인)": "今すぐ削除 (確認)",
"지금 짝 추가": "今のペアを追加", "지금 짝 추가": "今のペアを追加",
"지금 짝을 즐겨찾기에": "今のペアをお気に入りに", "지금 짝을 즐겨찾기에": "今のペアをお気に入りに",
"지금 폴더": "現在のフォルダー", "지금 폴더": "現在のフォルダー",
@ -2419,8 +2448,10 @@
"지금까지 찾은 {0}개 모두": "これまでに見つかった {0} 件すべて", "지금까지 찾은 {0}개 모두": "これまでに見つかった {0} 件すべて",
"지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "これはデュアルアプリ (複製したアプリ) です。Android はデュアルアプリにこの権限を与えない場合があります。許可画面でオンにできない場合は、バッジのない元の JJ_MKVMaker アイコンから開いてください。", "지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "これはデュアルアプリ (複製したアプリ) です。Android はデュアルアプリにこの権限を与えない場合があります。許可画面でオンにできない場合は、バッジのない元の JJ_MKVMaker アイコンから開いてください。",
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "前回の実行が正常に終了しませんでした(最終確認 {0}:{1})。作業記録に残しました。", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "前回の実行が正常に終了しませんでした(最終確認 {0}:{1})。作業記録に残しました。",
"지난 업데이트가 남긴 임시 파일을 지움: {0}": "前回のアップデートが残した一時ファイルを削除: {0}",
"지난 작업 기록": "過去の作業記録", "지난 작업 기록": "過去の作業記録",
"지문 확인하고 연결": "フィンガープリントを確認して接続", "지문 확인하고 연결": "フィンガープリントを確認して接続",
"지우고 맞추기": "削除して同期",
"지우고 초기화": "削除して初期化", "지우고 초기화": "削除して初期化",
"지우기": "削除", "지우기": "削除",
"지우기 포함": "削除あり", "지우기 포함": "削除あり",
@ -2575,6 +2606,9 @@
"큰 파일": "大きいファイル", "큰 파일": "大きいファイル",
"큰 파일이 없습니다": "大きいファイルはありません", "큰 파일이 없습니다": "大きいファイルはありません",
"큰 폴더": "大きいフォルダー", "큰 폴더": "大きいフォルダー",
"큰따옴표 (\") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}": "ダブルクォート (\") を含むオプションは Windows の rsync に渡せません: {0}",
"큰따옴표 (\") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}": "ダブルクォート (\") を含む引数は Windows の rsync にそのまま渡せないため、実行しませんでした: {0}",
"큰따옴표 (\") 를 빼 주세요": "二重引用符 (\") を外してください",
"키 있음": "キーあり", "키 있음": "キーあり",
"타갈로그어": "タガログ語", "타갈로그어": "タガログ語",
"타밀어": "タミル語", "타밀어": "タミル語",
@ -2591,6 +2625,7 @@
"토": "土", "토": "土",
"토렌트 · 마그넷 다운로드": "トレント・マグネットのダウンロード", "토렌트 · 마그넷 다운로드": "トレント・マグネットのダウンロード",
"통과": "合格", "통과": "合格",
"트래커 · 피어에 닿지 않습니다 ({0}초째)": "トラッカー・ピアに届きません ({0} 秒)",
"트랙 #{0} ({1}){2}": "トラック #{0}({1}){2}", "트랙 #{0} ({1}){2}": "トラック #{0}({1}){2}",
"트랙 {0}": "トラック {0}", "트랙 {0}": "トラック {0}",
"트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "トレイアイコンを押すか、下のショートカットで再表示します", "트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "トレイアイコンを押すか、下のショートカットで再表示します",
@ -2697,6 +2732,7 @@
"표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "表示名はその場で変更できます。動画の行を 3回押すと最初のボタンのフォルダーに移動します", "표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "表示名はその場で変更できます。動画の行を 3回押すと最初のボタンのフォルダーに移動します",
"표시 지우기": "マークを解除", "표시 지우기": "マークを解除",
"표준": "標準", "표준": "標準",
"표준 입력 (-) 은 쓸 수 없습니다": "標準入力 (-) は使えません",
"푸는 중": "展開中", "푸는 중": "展開中",
"푸는 중: {0}": "展開中: {0}", "푸는 중: {0}": "展開中: {0}",
"풀 수 없는 이름 {0}개": "展開できない名前 {0} 件", "풀 수 없는 이름 {0}개": "展開できない名前 {0} 件",

View File

@ -73,6 +73,7 @@
"- 감지한 언어": "- 检测到的语言", "- 감지한 언어": "- 检测到的语言",
"-1 은 아무렇게나": "-1 为随机", "-1 은 아무렇게나": "-1 为随机",
".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)", ".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)",
"/ 로 시작하는 전체 경로로": "请使用以 / 开头的完整路径",
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 不限制。适用于所有方式: rsync 用 --bwlimit,当前方式由应用调节,robocopy 用 /IPG 近似。也可直接写在选项栏中 (优先)", "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 不限制。适用于所有方式: rsync 用 --bwlimit,当前方式由应用调节,robocopy 用 /IPG 近似。也可直接写在选项栏中 (优先)",
"0 으로": "归零", "0 으로": "归零",
"1/2 · AI 그림으로 다듬는 중": "1/2 · 正在用 AI 图片优化", "1/2 · AI 그림으로 다듬는 중": "1/2 · 正在用 AI 图片优化",
@ -153,6 +154,7 @@
"Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "已从 Android 自动备份恢复之前的设置 (视频列表 {0} 个 · WebDAV 服务器 {1} 个)", "Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "已从 Android 自动备份恢复之前的设置 (视频列表 {0} 个 · WebDAV 服务器 {1} 个)",
"Android: Tor 화면이 열려 있는 동안": "Android: Tor 画面打开期间", "Android: Tor 화면이 열려 있는 동안": "Android: Tor 画面打开期间",
"BPE 토크나이저가 아닙니다: {0}": "不是 BPE 分词器: {0}", "BPE 토크나이저가 아닙니다: {0}": "不是 BPE 分词器: {0}",
"C:\\ 처럼 드라이브로 시작하는 전체 경로로": "请使用以驱动器开头的完整路径,如 C:\\",
"Chrome (내려받기)": "Chrome(下载)", "Chrome (내려받기)": "Chrome(下载)",
"Chrome (내장)": "Chrome(内置)", "Chrome (내장)": "Chrome(内置)",
"Chrome 에서 가져오기": "从 Chrome 导入", "Chrome 에서 가져오기": "从 Chrome 导入",
@ -166,6 +168,7 @@
"Chrome 엔진을 시작하지 못했습니다": "无法启动 Chrome 引擎", "Chrome 엔진을 시작하지 못했습니다": "无法启动 Chrome 引擎",
"Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "无法启动 Chrome 引擎。请在设置中切换为 Edge。\n{0}", "Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "无法启动 Chrome 引擎。请在设置中切换为 Edge。\n{0}",
"Chrome 엔진의 연결 통로를 다시 열 수 없습니다. 프로그램을 다시 시작하세요.": "无法重新打开 Chrome 引擎的连接通道。请重新启动应用。", "Chrome 엔진의 연결 통로를 다시 열 수 없습니다. 프로그램을 다시 시작하세요.": "无法重新打开 Chrome 引擎的连接通道。请重新启动应用。",
"DNS 강제 (환경 설정 > 네트워크 · DNS)": "强制 DNS (设置 > 网络 · DNS)",
"DNS 강제 프록시 시작: 127.0.0.1:{0} ({1})": "DNS 强制代理已启动: 127.0.0.1:{0} ({1})", "DNS 강제 프록시 시작: 127.0.0.1:{0} ({1})": "DNS 强制代理已启动: 127.0.0.1:{0} ({1})",
"DNS 강제 프록시: 비밀값이 없는 연결을 거절 ({0})": "DNS 强制代理:已拒绝没有密钥的连接({0})", "DNS 강제 프록시: 비밀값이 없는 연결을 거절 ({0})": "DNS 强制代理:已拒绝没有密钥的连接({0})",
"DNS 강제 프록시: 비밀값이 틀린 연결을 거절 ({0})": "DNS 强制代理:已拒绝密钥错误的连接({0})", "DNS 강제 프록시: 비밀값이 틀린 연결을 거절 ({0})": "DNS 强制代理:已拒绝密钥错误的连接({0})",
@ -355,6 +358,7 @@
"VLC 로 열기": "用 VLC 打开", "VLC 로 열기": "用 VLC 打开",
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失败: {1} {2}{3}", "WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失败: {1} {2}{3}",
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "将 WebDAV · OpenSubtitles 密码保存在本设备的安全存储中。关闭后不保存 (已有的也会删除),每次启动应用时重新输入", "WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "将 WebDAV · OpenSubtitles 密码保存在本设备的安全存储中。关闭后不保存 (已有的也会删除),每次启动应用时重新输入",
"WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)": "通过 WebDAV · SFTP · FTP · SMB 下载或上传时,若这段时间内没有任何数据往来,将提示失败 (服务器停止时不会一直等待)",
"WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV · SFTP · SMB · FTP · OpenSubtitles 密码、SFTP 密钥和 API 密钥。保留服务器列表和用户名", "WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV · SFTP · SMB · FTP · OpenSubtitles 密码、SFTP 密钥和 API 密钥。保留服务器列表和用户名",
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).": "WebDAV · SFTP · SMB · FTP:由应用代替 rsync 直接同步 (比较大小和修改时间,遵循选项中的 -u · --delete · --remove-source-files · 过滤 (--exclude · --include · --filter · --delete-excluded))。", "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).": "WebDAV · SFTP · SMB · FTP:由应用代替 rsync 直接同步 (比较大小和修改时间,遵循选项中的 -u · --delete · --remove-source-files · 过滤 (--exclude · --include · --filter · --delete-excluded))。",
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV · SFTP · SMB · FTP:由应用代替 rsync 直接同步 (比较大小和修改时间,遵循选项中的 -u · --delete · --remove-source-files)。", "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV · SFTP · SMB · FTP:由应用代替 rsync 直接同步 (比较大小和修改时间,遵循选项中的 -u · --delete · --remove-source-files)。",
@ -524,6 +528,7 @@
"{0} 을(를) 설치했습니다.": "已安装 {0}。", "{0} 을(를) 설치했습니다.": "已安装 {0}。",
"{0} 을(를) 지울까요?": "要删除 {0} 吗?", "{0} 을(를) 지울까요?": "要删除 {0} 吗?",
"{0} 의 SHA256 이 다릅니다 (손상되었거나 다른 파일). 설치를 멈췄습니다.": "{0} 的 SHA256 不一致(已损坏或是其他文件)。已停止安装。", "{0} 의 SHA256 이 다릅니다 (손상되었거나 다른 파일). 설치를 멈췄습니다.": "{0} 的 SHA256 不一致(已损坏或是其他文件)。已停止安装。",
"{0} 의 값이 없습니다": "{0} 缺少值",
"{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} 为空 (插入存储卡、U 盘或光盘后再点按)。", "{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} 为空 (插入存储卡、U 盘或光盘后再点按)。",
"{0} 이상": "{0} 及以上", "{0} 이상": "{0} 及以上",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。",
@ -609,6 +614,7 @@
"{0}장을 올려 저장했습니다{1}": "已提高并保存 {0} 页{1}", "{0}장을 올려 저장했습니다{1}": "已提高并保存 {0} 页{1}",
"{0}줄": "{0} 行", "{0}줄": "{0} 行",
"{0}초": "{0} 秒", "{0}초": "{0} 秒",
"{0}초 (기본)": "{0} 秒 (默认)",
"{0}초 안에 준비되지 않았습니다.": "未能在 {0} 秒内准备好。", "{0}초 안에 준비되지 않았습니다.": "未能在 {0} 秒内准备好。",
"{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "每 {0} 秒检查源文件夹并同步到目标文件夹 (应用运行期间)。", "{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "每 {0} 秒检查源文件夹并同步到目标文件夹 (应用运行期间)。",
"· Android 의 yt-dlp 새 버전 확인 (다운로드 도구 안에서 따로 연결)": "· Android 上的 yt-dlp 新版本检查 (在下载工具内部另行连接)", "· Android 의 yt-dlp 새 버전 확인 (다운로드 도구 안에서 따로 연결)": "· Android 上的 yt-dlp 新版本检查 (在下载工具内部另行连接)",
@ -653,6 +659,7 @@
"감지한 언어: {0} (자동 감지)": "检测到的语言:{0}(自动检测)", "감지한 언어: {0} (자동 감지)": "检测到的语言:{0}(自动检测)",
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "检测到的语言:{0} - 如有误,请用其他语言重新生成", "감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "检测到的语言:{0} - 如有误,请用其他语言重新生成",
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "仅当检测到的语言与要生成的语言不同时下载 {0}", "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "仅当检测到的语言与要生成的语言不同时下载 {0}",
"값을 적어 주세요 (예: --exclude=*.tmp)": "请填写值 (例: --exclude=*.tmp)",
"강하게 (0.4)": "强 (0.4)", "강하게 (0.4)": "强 (0.4)",
"강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "越强优化越多,但可能与原图不同", "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "越强优化越多,但可能与原图不同",
"같습니다 ✔": "相同 ✔", "같습니다 ✔": "相同 ✔",
@ -722,6 +729,7 @@
"경로 입력 · 네트워크 서버": "输入路径 · 网络服务器", "경로 입력 · 네트워크 서버": "输入路径 · 网络服务器",
"경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "路径过长,无法放入回收站 (Windows 回收站不接受超过 260 个字符的路径)。", "경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "路径过长,无法放入回收站 (Windows 回收站不接受超过 260 个字符的路径)。",
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "路径过长 (Windows 回收站不接受超过 260 个字符的路径) - 可以永久删除", "경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "路径过长 (Windows 回收站不接受超过 260 个字符的路径) - 可以永久删除",
"경로로 읽힙니다: {0}": "会被当作路径: {0}",
"경로를 복사했습니다: {0}": "已复制路径: {0}", "경로를 복사했습니다: {0}": "已复制路径: {0}",
"계산 안 함": "不计算", "계산 안 함": "不计算",
"계속": "持续", "계속": "持续",
@ -746,6 +754,7 @@
"고치기": "编辑", "고치기": "编辑",
"고친 옵션을 저장하지 않았습니다": "修改的选项尚未保存", "고친 옵션을 저장하지 않았습니다": "修改的选项尚未保存",
"고화질": "高画质", "고화질": "高画质",
"곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "请用直引号和连字符 (-) 重新输入",
"골라서 시작": "选择后开始", "골라서 시작": "选择后开始",
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "无法使用公共存储 (Download),因此无法回退。", "공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "无法使用公共存储 (Download),因此无法回退。",
"공용 폴더에 받은 파일 (동영상 · 그림 등)": "下载到公共文件夹的文件 (视频、图片等)", "공용 폴더에 받은 파일 (동영상 · 그림 등)": "下载到公共文件夹的文件 (视频、图片等)",
@ -815,6 +824,7 @@
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "要在本设备上绘制,需要先下载模型 ({0})", "기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "要在本设备上绘制,需要先下载模型 ({0})",
"기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "在某些设备上会打开应用列表,请在列表中找到 JJ_MKVMaker 并开启。", "기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "在某些设备上会打开应用列表,请在列表中找到 JJ_MKVMaker 并开启。",
"기능 버튼 줄": "功能按钮栏", "기능 버튼 줄": "功能按钮栏",
"기록 · 배치 파일은 전체 경로로 적어 주세요: {0}": "请填写日志或批处理文件的完整路径: {0}",
"기본": "默认", "기본": "默认",
"기본 DNS": "首选 DNS", "기본 DNS": "首选 DNS",
"기본 앱으로 열기": "用默认应用打开", "기본 앱으로 열기": "用默认应用打开",
@ -1035,6 +1045,7 @@
"대상 폴더가 없습니다: {0}": "目标文件夹不存在: {0}", "대상 폴더가 없습니다: {0}": "目标文件夹不存在: {0}",
"대상에만 있는 {0}개를 지울지 확인이 필요합니다 (확인 전에는 지우지 않음)": "需要确认是否删除仅在目标中的 {0} 个项目 (确认前不会删除)", "대상에만 있는 {0}개를 지울지 확인이 필요합니다 (확인 전에는 지우지 않음)": "需要确认是否删除仅在目标中的 {0} 个项目 (确认前不会删除)",
"대상에서 {0}개를 지울까요?": "从目标中删除 {0} 个项目吗?", "대상에서 {0}개를 지울까요?": "从目标中删除 {0} 个项目吗?",
"대상에서 지울까요?": "要从目标中删除吗?",
"대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "已传到目标的文件从源中删除 (移动)", "대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "已传到目标的文件从源中删除 (移动)",
"대상이 원본과 같거나 원본 안에 있습니다": "目标与源相同或位于源内", "대상이 원본과 같거나 원본 안에 있습니다": "目标与源相同或位于源内",
"대상이 원본과 같거나 원본 안에 있습니다 (맞출 때마다 원본이 바뀝니다). 다른 폴더를 고르세요.": "目标与源相同或位于源内 (每次同步都会改变源)。请选择其他文件夹。", "대상이 원본과 같거나 원본 안에 있습니다 (맞출 때마다 원본이 바뀝니다). 다른 폴더를 고르세요.": "目标与源相同或位于源内 (每次同步都会改变源)。请选择其他文件夹。",
@ -1124,6 +1135,8 @@
"등록 중 일부가 실패했습니다.": "部分注册失败。", "등록 중 일부가 실패했습니다.": "部分注册失败。",
"등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "已注册: “用 JJ_MKVMaker 播放” · “用 JJ_MKVMaker 生成字幕”(Windows 11: 显示更多选项)", "등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "已注册: “用 JJ_MKVMaker 播放” · “用 JJ_MKVMaker 生成字幕”(Windows 11: 显示更多选项)",
"따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "不适用: Tor 画面 (名称也在 Tor 内解析)、IP 地址、家庭网络内的名称 (不带点的 NAS · PC 名称、*.local、Tailscale 的 *.ts.net 等使用设备 DNS)", "따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "不适用: Tor 画面 (名称也在 Tor 内解析)、IP 地址、家庭网络内的名称 (不带点的 NAS · PC 名称、*.local、Tailscale 的 *.ts.net 等使用设备 DNS)",
"따옴표가 닫히지 않았습니다: {0}": "引号未闭合: {0}",
"따옴표를 닫아 주세요": "请闭合引号",
"또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "或用另一台设备的相机扫描二维码 (已包含代码)", "또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "或用另一台设备的相机扫描二维码 (已包含代码)",
"라오어": "老挝语", "라오어": "老挝语",
"라이선스 원문 보기": "查看许可证原文", "라이선스 원문 보기": "查看许可证原文",
@ -1139,6 +1152,7 @@
"링크 {0}개는 세지 않음": "{0} 个链接未计入", "링크 {0}개는 세지 않음": "{0} 个链接未计入",
"링크 · junction {0}개 (따라가지 않고 세지 않음)": "{0} 个链接 / 联接 (不跟随、不计入)", "링크 · junction {0}개 (따라가지 않고 세지 않음)": "{0} 个链接 / 联接 (不跟随、不计入)",
"링크 · junction 은 넣지 않습니다": "不包含链接和 junction", "링크 · junction 은 넣지 않습니다": "不包含链接和 junction",
"마그넷 · 토렌트가 시작되지 않으면 알리기": "磁力链接 · 种子无法开始时提醒",
"마스터 · 최상 비밀번호": "主密码和最高密码", "마스터 · 최상 비밀번호": "主密码和最高密码",
"마스터 비밀번호": "主密码", "마스터 비밀번호": "主密码",
"마스터 비밀번호 넣기": "输入主密码", "마스터 비밀번호 넣기": "输入主密码",
@ -1194,10 +1208,12 @@
"먼저 mDNS 로 묻고 (이름을 알리는 NAS), 이어서 이 기기가 연결된 망 (Wi-Fi · 유선) 의 기기들에 SMB (445) 연결만 해 보고 바로 닫습니다. 파일 · 계정 정보는 보내지 않습니다.": "先通过 mDNS 询问 (会公布名称的 NAS),再仅尝试与本设备所在网络 (Wi-Fi · 有线) 中的设备建立 SMB (445) 连接并立即关闭。不会发送文件或账户信息。", "먼저 mDNS 로 묻고 (이름을 알리는 NAS), 이어서 이 기기가 연결된 망 (Wi-Fi · 유선) 의 기기들에 SMB (445) 연결만 해 보고 바로 닫습니다. 파일 · 계정 정보는 보내지 않습니다.": "先通过 mDNS 询问 (会公布名称的 NAS),再仅尝试与本设备所在网络 (Wi-Fi · 有线) 中的设备建立 SMB (445) 连接并立即关闭。不会发送文件或账户信息。",
"멈추고 옮기기": "停止并移动", "멈추고 옮기기": "停止并移动",
"멈춤": "已停止", "멈춤": "已停止",
"멈춤 (시작 안 됨)": "停滞 (未开始)",
"멈춤 (이번 실행 동안)": "暂停 (本次运行期间)", "멈춤 (이번 실행 동안)": "暂停 (本次运行期间)",
"멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "已暂停 (本次运行期间) · 按开始按钮重新同步", "멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "已暂停 (本次运行期间) · 按开始按钮重新同步",
"멈춤 · {0}": "已停止 · {0}", "멈춤 · {0}": "已停止 · {0}",
"메모리가 모자라 Android 가 AI 엔진을 끝냈습니다. 다른 앱을 닫거나 환경 설정의 해상도 올리기 메모리를 \"적게\" 로 해 보세요": "内存不足,Android 结束了 AI 引擎。请关闭其他应用,或在设置中将提高分辨率的内存设为“少”", "메모리가 모자라 Android 가 AI 엔진을 끝냈습니다. 다른 앱을 닫거나 환경 설정의 해상도 올리기 메모리를 \"적게\" 로 해 보세요": "内存不足,Android 结束了 AI 引擎。请关闭其他应用,或在设置中将提高分辨率的内存设为“少”",
"메타데이터나 피어를 이 시간 동안 못 받으면 그 줄에 이유 후보와 [다시 시도] 를 보입니다": "如果在此时间内未获得元数据或对等方,该行会显示可能原因和 [重试]",
"모니터링": "监控", "모니터링": "监控",
"모니터링의 [실행]": "监视中的 [执行]", "모니터링의 [실행]": "监视中的 [执行]",
"모델 (선택)": "模型 (可选)", "모델 (선택)": "模型 (可选)",
@ -1228,6 +1244,8 @@
"목록": "列表", "목록": "列表",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "隐藏列表 · 视频旁 · 视频上方出现的广告区域", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "隐藏列表 · 视频旁 · 视频上方出现的广告区域",
"목록 보기": "查看列表", "목록 보기": "查看列表",
"목록 파일은 전체 경로로 적어 주세요: {0}": "请填写列表文件的完整路径: {0}",
"목록 파일의 전체 경로를 적어 주세요": "请填写列表文件的完整路径",
"목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "如果列表中没有 JJ_MKVMaker,请用 [允许其他应用] 选择 JJ_MKVMaker.exe 并勾选“专用”。更改后请关闭共享再重新开启。", "목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "如果列表中没有 JJ_MKVMaker,请用 [允许其他应用] 选择 JJ_MKVMaker.exe 并勾选“专用”。更改后请关闭共享再重新开启。",
"목록에서 동영상을 누르면 여기에 자세히 보입니다": "点按列表中的视频即可在此查看详情", "목록에서 동영상을 누르면 여기에 자세히 보입니다": "点按列表中的视频即可在此查看详情",
"목록에서 빼기": "从列表中移除", "목록에서 빼기": "从列表中移除",
@ -1257,6 +1275,7 @@
"묻기 (기본)": "询问 (默认)", "묻기 (기본)": "询问 (默认)",
"물어보지 않기": "不询问", "물어보지 않기": "不询问",
"미리 보기": "快速预览", "미리 보기": "快速预览",
"미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다": "预览中没有要删除的项目 - 但由于上面的规则,实际上可能会被删除",
"미리 볼 수 없습니다: {0}": "无法预览: {0}", "미리 볼 수 없습니다: {0}": "无法预览: {0}",
"미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "无法预览 (文本不是 UTF-8)", "미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "无法预览 (文本不是 UTF-8)",
"미리 볼 수 없음 (글자 코드를 알 수 없습니다)": "无法预览 (未知的文本编码)", "미리 볼 수 없음 (글자 코드를 알 수 없습니다)": "无法预览 (未知的文本编码)",
@ -1345,6 +1364,7 @@
"밤 0~6시": "夜间 0~6 点", "밤 0~6시": "夜间 0~6 点",
"방법: {0}": "方式: {0}", "방법: {0}": "方式: {0}",
"방식": "方式", "방식": "方式",
"방화벽 · 공유기가 토렌트를 막음": "防火墙 · 路由器阻止了 BitTorrent",
"방화벽 허용 앱 열기": "打开允许的应用", "방화벽 허용 앱 열기": "打开允许的应用",
"방화벽에서 이 앱을 허용합니다: 제어판 > Windows Defender 방화벽 > Windows Defender 방화벽을 통해 앱 또는 기능 허용 > [설정 변경] > JJ_MKVMaker 의 \"개인\" 에 체크 > [확인].": "在防火墙中允许此应用:控制面板 > Windows Defender 防火墙 > 允许应用或功能通过 Windows Defender 防火墙 > [更改设置] > 勾选 JJ_MKVMaker 的“专用” > [确定]。", "방화벽에서 이 앱을 허용합니다: 제어판 > Windows Defender 방화벽 > Windows Defender 방화벽을 통해 앱 또는 기능 허용 > [설정 변경] > JJ_MKVMaker 의 \"개인\" 에 체크 > [확인].": "在防火墙中允许此应用:控制面板 > Windows Defender 防火墙 > 允许应用或功能通过 Windows Defender 防火墙 > [更改设置] > 勾选 JJ_MKVMaker 的“专用” > [确定]。",
"배율": "倍率", "배율": "倍率",
@ -1551,6 +1571,7 @@
"서버가 그림을 돌려주지 않았습니다": "服务器没有返回图片", "서버가 그림을 돌려주지 않았습니다": "服务器没有返回图片",
"서버가 너무 오래 답하지 않습니다": "服务器长时间没有响应", "서버가 너무 오래 답하지 않습니다": "服务器长时间没有响应",
"서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "服务器要求改用其他地址 ({0})。请检查服务器设置中的地址。", "서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "服务器要求改用其他地址 ({0})。请检查服务器设置中的地址。",
"서버가 답하지 않습니다 ({0}초)": "服务器没有响应 ({0} 秒)",
"서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "服务器在开始加密 (FTPS) 前发送了意外的回复。已断开连接", "서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "服务器在开始加密 (FTPS) 前发送了意外的回复。已断开连接",
"서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "服务器不支持加密 (FTPS) ({0} {1})。请在服务器上启用 FTPS,或在了解风险后选择\"明文 FTP\"", "서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "服务器不支持加密 (FTPS) ({0} {1})。请在服务器上启用 FTPS,或在了解风险后选择\"明文 FTP\"",
"서버가 이 기능을 허용하지 않음": "服务器不允许此操作", "서버가 이 기능을 허용하지 않음": "服务器不允许此操作",
@ -1732,6 +1753,7 @@
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "里面有 {0} 个文件在接收端较新 - 合并时会跳过(保留在源中)", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "里面有 {0} 个文件在接收端较新 - 合并时会跳过(保留在源中)",
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖",
"알고 실행합니다 (지울 목록이 실제와 다를 수 있음)": "我已了解并执行 (删除列表可能与实际不同)", "알고 실행합니다 (지울 목록이 실제와 다를 수 있음)": "我已了解并执行 (删除列表可能与实际不同)",
"알리지 않음": "不提醒",
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)",
"암호 ZIP 으로 묶는 중 · {0}": "正在创建加密 ZIP · {0}", "암호 ZIP 으로 묶는 중 · {0}": "正在创建加密 ZIP · {0}",
"암호로 묶기": "加密压缩", "암호로 묶기": "加密压缩",
@ -1878,6 +1900,8 @@
"옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要移动的项目 (点按或用右侧圆圈标记)。", "옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要移动的项目 (点按或用右侧圆圈标记)。",
"옮길까요?": "移动吗?", "옮길까요?": "移动吗?",
"옵션": "选项", "옵션": "选项",
"옵션은 - 나 -- 로 시작합니다": "选项以 - 或 -- 开头",
"옵션을 알아볼 수 없습니다: {0} - {1}": "无法识别的选项: {0} - {1}",
"옵션을 저장했습니다": "已保存选项", "옵션을 저장했습니다": "已保存选项",
"완료": "完成", "완료": "完成",
"완료 시 자동 동영상 추가": "完成后自动添加视频", "완료 시 자동 동영상 추가": "完成后自动添加视频",
@ -1899,6 +1923,7 @@
"우르두어": "乌尔都语", "우르두어": "乌尔都语",
"우크라이나어": "乌克兰语", "우크라이나어": "乌克兰语",
"원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "远程 (WebDAV · SFTP · SMB · FTP) 不遵循选项中的过滤规则 (--exclude · --include · --filter 等) - 被排除的项目也会复制,如包含删除则会删除。上面的比较列表就是实际会执行的操作。", "원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "远程 (WebDAV · SFTP · SMB · FTP) 不遵循选项中的过滤规则 (--exclude · --include · --filter 等) - 被排除的项目也会复制,如包含删除则会删除。上面的比较列表就是实际会执行的操作。",
"원격 서버 응답 기다리기": "等待远程服务器响应",
"원래 이름으로 되돌리지 못한 것: {0}": "未能恢复原名称: {0}", "원래 이름으로 되돌리지 못한 것: {0}": "未能恢复原名称: {0}",
"원래 자리에 같은 이름이 이미 있습니다": "原位置已存在同名项目", "원래 자리에 같은 이름이 이미 있습니다": "原位置已存在同名项目",
"원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "原文件夹已不存在,未能恢复 (请在回收站中手动还原)", "원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "原文件夹已不存在,未能恢复 (请在回收站中手动还原)",
@ -2042,6 +2067,7 @@
"이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "此连接既未签名也未加密 ({0})。同一网络中的其他人可能查看或篡改文件内容,建议在 NAS 设置中开启 SMB 签名或 SMB3 加密", "이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "此连接既未签名也未加密 ({0})。同一网络中的其他人可能查看或篡改文件内容,建议在 NAS 设置中开启 SMB 签名或 SMB3 加密",
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "此连接未加密 ({0})。同一网络中的其他人可能看到文件内容,建议在 NAS 设置中开启 SMB3 加密", "이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "此连接未加密 ({0})。同一网络中的其他人可能看到文件内容,建议在 NAS 设置中开启 SMB3 加密",
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}": "这些选项中的部分过滤规则无法预览 - 实际可能会删除更多: {0}", "이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}": "这些选项中的部分过滤规则无法预览 - 实际可能会删除更多: {0}",
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다": "这些选项中的部分过滤规则无法预览,因此不会自动删除",
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "此位置 (网络驱动器 · 网络共享 · U 盘等) 没有回收站,会直接删除。", "이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "此位置 (网络驱动器 · 网络共享 · U 盘等) 没有回收站,会直接删除。",
"이 이동 버튼 지우기": "删除此移动按钮", "이 이동 버튼 지우기": "删除此移动按钮",
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次执行此移动时将使用这些选项。现在用新选项执行吗?", "이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次执行此移动时将使用这些选项。现在用新选项执行吗?",
@ -2129,6 +2155,7 @@
"이번 업데이트로 바뀐 기본 설정": "本次更新更改的默认设置", "이번 업데이트로 바뀐 기본 설정": "本次更新更改的默认设置",
"이번에는 쓰지 않음 (번역 없음)": "本次不使用 (无需翻译)", "이번에는 쓰지 않음 (번역 없음)": "本次不使用 (无需翻译)",
"이어 받기": "继续下载", "이어 받기": "继续下载",
"이유 후보: {0}": "可能原因: {0}",
"이전": "上一个", "이전": "上一个",
"이전 (P)": "上一个(P)", "이전 (P)": "上一个(P)",
"이전 설정을 되살렸습니다": "已恢复之前的设置", "이전 설정을 되살렸습니다": "已恢复之前的设置",
@ -2139,6 +2166,7 @@
"인증서 확인 안 함": "不验证证书", "인증서 확인 안 함": "不验证证书",
"인증서: 공인 인증서면 그대로 연결, 자체 서명이면 [연결 확인] 때 지문을 보이고 묻습니다": "证书:公共信任的证书直接连接;自签名证书会在 [测试连接] 时显示指纹并询问", "인증서: 공인 인증서면 그대로 연결, 자체 서명이면 [연결 확인] 때 지문을 보이고 묻습니다": "证书:公共信任的证书直接连接;自签名证书会在 [测试连接] 时显示指纹并询问",
"인코딩": "编码", "인코딩": "编码",
"인터넷 IPv4 가 없음 (IPv6 만)": "没有互联网 IPv4 (仅 IPv6)",
"인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "请检查网络连接后,在 设置 > 检查必需程序 中重试。\n\n{0}", "인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "请检查网络连接后,在 设置 > 检查必需程序 中重试。\n\n{0}",
"인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "请检查网络连接或 设置 > 网络 · DNS 中的服务器地址。", "인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "请检查网络连接或 设置 > 网络 · DNS 中的服务器地址。",
"인터넷 자막 (OpenSubtitles.com)": "网络字幕(OpenSubtitles.com)", "인터넷 자막 (OpenSubtitles.com)": "网络字幕(OpenSubtitles.com)",
@ -2407,6 +2435,7 @@
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "当前版本: v{0} → 新版本: v{1}", "지금 쓰는 버전: v{0} → 새 버전: v{1}": "当前版本: v{0} → 新版本: v{1}",
"지금 작업 취소: {0}": "已取消当前任务: {0}", "지금 작업 취소: {0}": "已取消当前任务: {0}",
"지금 작업만 (대기 {0}개는 계속)": "仅当前任务 (等待中的 {0} 个继续)", "지금 작업만 (대기 {0}개는 계속)": "仅当前任务 (等待中的 {0} 个继续)",
"지금 지우기 (확인)": "立即删除 (确认)",
"지금 짝 추가": "添加当前一对", "지금 짝 추가": "添加当前一对",
"지금 짝을 즐겨찾기에": "将当前一对加入收藏", "지금 짝을 즐겨찾기에": "将当前一对加入收藏",
"지금 폴더": "当前文件夹", "지금 폴더": "当前文件夹",
@ -2419,8 +2448,10 @@
"지금까지 찾은 {0}개 모두": "目前找到的全部 {0} 个", "지금까지 찾은 {0}개 모두": "目前找到的全部 {0} 个",
"지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "当前是双开应用 (复制的应用)。Android 可能不会给双开应用此权限。如果无法开启,请从没有徽标的原 JJ_MKVMaker 图标打开。", "지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "当前是双开应用 (复制的应用)。Android 可能不会给双开应用此权限。如果无法开启,请从没有徽标的原 JJ_MKVMaker 图标打开。",
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "上次运行未正常结束(最后检查 {0}:{1})。已记录在任务记录中。", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "上次运行未正常结束(最后检查 {0}:{1})。已记录在任务记录中。",
"지난 업데이트가 남긴 임시 파일을 지움: {0}": "已删除上次更新留下的临时文件: {0}",
"지난 작업 기록": "旧任务记录", "지난 작업 기록": "旧任务记录",
"지문 확인하고 연결": "确认指纹并连接", "지문 확인하고 연결": "确认指纹并连接",
"지우고 맞추기": "删除并同步",
"지우고 초기화": "删除并重置", "지우고 초기화": "删除并重置",
"지우기": "删除", "지우기": "删除",
"지우기 포함": "含删除", "지우기 포함": "含删除",
@ -2575,6 +2606,9 @@
"큰 파일": "大文件", "큰 파일": "大文件",
"큰 파일이 없습니다": "没有大文件", "큰 파일이 없습니다": "没有大文件",
"큰 폴더": "大文件夹", "큰 폴더": "大文件夹",
"큰따옴표 (\") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}": "含有双引号 (\") 的选项无法传给 Windows 上的 rsync: {0}",
"큰따옴표 (\") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}": "含有双引号 (\") 的参数无法原样传给 Windows 上的 rsync,因此未执行: {0}",
"큰따옴표 (\") 를 빼 주세요": "请去掉双引号 (\")",
"키 있음": "已有密钥", "키 있음": "已有密钥",
"타갈로그어": "他加禄语", "타갈로그어": "他加禄语",
"타밀어": "泰米尔语", "타밀어": "泰米尔语",
@ -2591,6 +2625,7 @@
"토": "六", "토": "六",
"토렌트 · 마그넷 다운로드": "种子 · 磁力链接下载", "토렌트 · 마그넷 다운로드": "种子 · 磁力链接下载",
"통과": "通过", "통과": "通过",
"트래커 · 피어에 닿지 않습니다 ({0}초째)": "无法连接 tracker 或对等方 ({0} 秒)",
"트랙 #{0} ({1}){2}": "轨道 #{0}({1}){2}", "트랙 #{0} ({1}){2}": "轨道 #{0}({1}){2}",
"트랙 {0}": "轨道 {0}", "트랙 {0}": "轨道 {0}",
"트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "点击托盘图标或用下方快捷键重新打开", "트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "点击托盘图标或用下方快捷键重新打开",
@ -2697,6 +2732,7 @@
"표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "显示名称可直接修改。连续点击视频行三次会移到第一个按钮的文件夹", "표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "显示名称可直接修改。连续点击视频行三次会移到第一个按钮的文件夹",
"표시 지우기": "清除标记", "표시 지우기": "清除标记",
"표준": "标准", "표준": "标准",
"표준 입력 (-) 은 쓸 수 없습니다": "不能使用标准输入 (-)",
"푸는 중": "正在解压", "푸는 중": "正在解压",
"푸는 중: {0}": "正在解压: {0}", "푸는 중: {0}": "正在解压: {0}",
"풀 수 없는 이름 {0}개": "{0} 个无法解压的名称", "풀 수 없는 이름 {0}개": "{0} 个无法解压的名称",

View File

@ -7,6 +7,7 @@ import 'package:jj_mkvmaker/core/languages.dart';
import 'package:jj_mkvmaker/core/models.dart'; import 'package:jj_mkvmaker/core/models.dart';
import 'package:jj_mkvmaker/services/model_store.dart'; import 'package:jj_mkvmaker/services/model_store.dart';
import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/services/platform_services.dart';
import 'support/temp_dir.dart';
/// 긴 영상 (JJ_LONG_VIDEO) 의 음성인식 도중 [작업 취소] → 몇 초 안에 멈춘다 (끝까지 기다리지 않음) /// 긴 영상 (JJ_LONG_VIDEO) 의 음성인식 도중 [작업 취소] → 몇 초 안에 멈춘다 (끝까지 기다리지 않음)
void main() { void main() {
@ -15,6 +16,7 @@ void main() {
testWidgets('음성인식 중 취소', (tester) async { testWidgets('음성인식 중 취소', (tester) async {
final video = Platform.environment['JJ_LONG_VIDEO']; final video = Platform.environment['JJ_LONG_VIDEO'];
if (video == null || !File(video).existsSync()) return markTestSkipped('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()); final c = AppController(PlatformServices.create());
await tester.runAsync(() async { await tester.runAsync(() async {
await c.init(); await c.init();

View File

@ -18,6 +18,7 @@ void main() {
testWidgets('영어 음성 영상 → AI 자막(ko/en/ja) → MKV', (tester) async { testWidgets('영어 음성 영상 → AI 자막(ko/en/ja) → MKV', (tester) async {
final dir = testTempDir('jj_e2e_'); final dir = testTempDir('jj_e2e_');
appTempInto(dir); // 앱 임시 폴더 (jj_mkvmaker) 도 이 아래로 - 끝에 함께 지움 (관리자 지시 10/20)
final video = p.join(dir.path, '영어 강의.mp4'); final video = p.join(dir.path, '영어 강의.mp4');
final speech = p.join(Directory.current.path, 'test_assets', 'speech_en.wav'); final speech = p.join(Directory.current.path, 'test_assets', 'speech_en.wav');
final r = await Process.run('ffmpeg', [ 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/core/output_paths.dart';
import 'package:jj_mkvmaker/services/model_store.dart'; import 'package:jj_mkvmaker/services/model_store.dart';
import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/services/platform_services.dart';
import 'support/temp_dir.dart';
/// 긴 실제 영상 (JJ_LONG_VIDEO) 으로 AI 자막: 원어 자동 → [JJ_LONG_TARGETS] (기본 ja,en). /// 긴 실제 영상 (JJ_LONG_VIDEO) 으로 AI 자막: 원어 자동 → [JJ_LONG_TARGETS] (기본 ja,en).
/// 번역이 제대로 됐는지 줄 단위로 센다 (영어 자막에 일본어가 남은 줄 · 빈 줄 · 같은 말 되풀이). /// 번역이 제대로 됐는지 줄 단위로 센다 (영어 자막에 일본어가 남은 줄 · 빈 줄 · 같은 말 되풀이).
@ -18,6 +19,7 @@ void main() {
final video = Platform.environment['JJ_LONG_VIDEO']; final video = Platform.environment['JJ_LONG_VIDEO'];
if (video == null || !File(video).existsSync()) return markTestSkipped('JJ_LONG_VIDEO 없음'); if (video == null || !File(video).existsSync()) return markTestSkipped('JJ_LONG_VIDEO 없음');
final targets = (Platform.environment['JJ_LONG_TARGETS'] ?? 'ja,en').split(','); final targets = (Platform.environment['JJ_LONG_TARGETS'] ?? 'ja,en').split(',');
appTempInto(testTempDir('jj_aitmp_')); // 앱 임시 폴더 (jj_mkvmaker) 를 시험 폴더로 - 끝에 지움 (관리자 지시 10/20)
final c = AppController(PlatformServices.create()); final c = AppController(PlatformServices.create());
final sw = Stopwatch()..start(); final sw = Stopwatch()..start();
await tester.runAsync(() async { await tester.runAsync(() async {

View File

@ -28,7 +28,7 @@ void main() {
await req.response.close(); await req.response.close();
}); });
final url = 'http://127.0.0.1:${server.port}/page'; final url = 'http://127.0.0.1:${server.port}/page';
final dataDir = testTempDir('jj_keep_').path; final dataDir = testTempDir('jj_keep_', heldByApp: true).path;
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings

View File

@ -32,7 +32,7 @@ void main() {
await req.response.close(); await req.response.close();
}); });
final url = 'http://127.0.0.1:${server.port}/'; final url = 'http://127.0.0.1:${server.port}/';
final dataDir = testTempDir('jj_webview_').path; final dataDir = testTempDir('jj_webview_', heldByApp: true).path;
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.webViewDataDir = dataDir; c.settings.webViewDataDir = dataDir;

View File

@ -34,7 +34,7 @@ void main() {
} }
CefRuntime.rememberStartState(); CefRuntime.rememberStartState();
expect(CefRuntime.readyThisRun, isTrue); expect(CefRuntime.readyThisRun, isTrue);
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_cef_real_').path; final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_cef_real_', heldByApp: true).path;
Directory(data).createSync(recursive: true); Directory(data).createSync(recursive: true);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings

View File

@ -25,7 +25,8 @@ void main() {
for (var i = 0; i < 8; i++) { for (var i = 0; i < 8; i++) {
ComGuard.releaseForTest(); ComGuard.releaseForTest();
} }
final dir = testTempDir('jj_com_').path; // Edge 가 앱이 끝날 때까지 사용자 데이터 폴더를 잡는다 (test_temp_check 가 찾음, 관리자 지시 10/20)
final dir = testTempDir('jj_com_', heldByApp: true).path;
final raw = await tester.runAsync<Object?>(() async { final raw = await tester.runAsync<Object?>(() async {
try { try {
await WebViewEnvironment.create(settings: WebViewEnvironmentSettings(userDataFolder: '${dir}_raw')); await WebViewEnvironment.create(settings: WebViewEnvironmentSettings(userDataFolder: '${dir}_raw'));

View File

@ -21,7 +21,7 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized(); IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('250: 닿지 않는 DNS → 브라우저에 앱 덮개 (Edge 실제)', (tester) async { testWidgets('250: 닿지 않는 DNS → 브라우저에 앱 덮개 (Edge 실제)', (tester) async {
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_dns_block_').path; final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_dns_block_', heldByApp: true).path;
Directory(data).createSync(recursive: true); Directory(data).createSync(recursive: true);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings

View File

@ -0,0 +1,201 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/download_manager.dart';
import 'package:jj_mkvmaker/app/net_guard.dart';
import 'package:jj_mkvmaker/core/download_detect.dart' show DownloadKind;
import 'package:jj_mkvmaker/platform/windows/aria2_backend.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/downloader.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/downloads_page.dart';
import 'package:path/path.dart' as p;
import 'support/temp_dir.dart';
/// 사용자 질문 (10/20) "마그넷 테스트 된 건가" - 지금까지는 단위 시험뿐이었다. 실제 마그넷을 앱의 다운로드 관리자 (aria2c) 로 받는다.
/// 공개된 합법 배포 파일: Debian 공식 netinst ISO 의 토렌트 (cdimage.debian.org). 정보 해시는 공식 .torrent 에서 계산해
/// 마그넷을 만들고 (판이 바뀌어도 맞게), 끝나면 공식 SHA256SUMS 와 맞춘다.
/// - DNS 시스템: 끝까지 받기 · 진행률 · 속도 · 일시 정지 / 재개 · SHA256
/// - DNS 강제 (8.8.8.8 · 8.8.4.4, 앱 안 프록시): 받기 시작 · 진행률 · 일시 정지 / 재개 · 취소 (받던 파일 지움)
/// 인터넷 · 디스크 (약 800MB) 를 쓰므로 JJ_REAL_MAGNET=1 일 때만:
/// flutter test integration_test\magnet_real_test.dart -d windows (PowerShell: $env:JJ_REAL_MAGNET='1')
/// 받은 파일은 시험 폴더 (%TEMP%\jj_magnet_*) 에 두고 끝나면 지운다.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
const base = 'https://cdimage.debian.org/debian-cd/current/amd64/bt-cd/';
for (final forced in [false, true]) {
testWidgets('마그넷 실제 받기 (DNS ${forced ? '강제 8.8.8.8' : '시스템'})', (tester) async {
if (Platform.environment['JJ_REAL_MAGNET'] != '1') return markTestSkipped('JJ_REAL_MAGNET=1 일 때만 (인터넷 · 약 800MB)');
final aria2c = p.join(Directory.current.path, 'third_party', 'tools', 'windows', 'aria2c.exe');
expect(File(aria2c).existsSync(), isTrue, reason: '앱에 든 aria2c (tool\\fetch_tools.ps1)');
final dir = testTempDir('jj_magnet_');
void log(String s) => debugPrint('MAGNET ${forced ? 'DNS강제' : 'DNS시스템'} ${DateTime.now().toIso8601String().substring(11, 19)} $s');
// 공식 토렌트 · SHA256 (판 이름은 목록에서)
late Uint8List torrent;
late String isoName, want;
await tester.runAsync(() async {
final http = HttpClient();
Future<List<int>> get(String url) async {
final res = await (await http.getUrl(Uri.parse(url))).close();
expect(res.statusCode, 200, reason: url);
return [for (final c in await res.toList()) ...c];
}
final index = utf8.decode(await get(base));
final name = RegExp(r'href="(debian-[0-9.]+-amd64-netinst\.iso)\.torrent"').firstMatch(index)![1]!;
isoName = name;
torrent = Uint8List.fromList(await get('$base$name.torrent'));
final sums = utf8.decode(await get('${base}SHA256SUMS'));
want = RegExp('^([0-9a-f]{64})\\s+${RegExp.escape(name)}\$', multiLine: true).firstMatch(sums)![1]!;
http.close();
});
final (infoHash, announce) = _torrentInfo(torrent);
final magnet = 'magnet:?xt=urn:btih:$infoHash&dn=${Uri.encodeComponent(isoName)}&tr=${Uri.encodeComponent(announce)}';
log('$isoName · $infoHash · 공식 SHA256 $want');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..downloadRoot = dir.path
..dnsMode = forced ? 'custom' : 'system'
..dnsPrimary = '8.8.8.8'
..dnsSecondary = '8.8.4.4';
final g = NetGuard.instance
..config = (() => c.settings.dnsConfig)
..log = log;
await tester.runAsync(g.ensure);
addTearDown(() async {
c.settings.dnsMode = 'system';
await g.ensure();
});
final d = DownloadManager(backends: [Aria2Backend(aria2c: aria2c)], settings: () => c.settings);
await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d)));
Future<void> wait(int ms) async {
for (var i = 0; i < ms ~/ 250; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 250)));
await tester.pump();
}
}
Future<bool> until(bool Function() ok, {required Duration within}) async {
final end = DateTime.now().add(within);
while (!ok() && DateTime.now().isBefore(end)) {
await wait(1000);
}
return ok();
}
// 받기 시작 (메타데이터 → 실제 받기) · 진행률 · 속도
final t = d.add(magnet);
expect(t, isNotNull);
expect(t!.kind, DownloadKind.torrent);
final speeds = <String>{};
final started = await until(() {
if (t.speed.isNotEmpty) speeds.add(t.speed);
return (t.receivedBytes ?? 0) >= 8 << 20 || t.state == DownloadState.failed;
}, within: const Duration(minutes: 5));
log('시작: ${t.state} ${t.receivedBytes}/${t.totalBytes} 속도 $speeds 오류 ${t.error}');
expect(started && t.state == DownloadState.downloading, isTrue, reason: '8MB 를 5분 안에 받지 못함: ${t.state} ${t.error}');
expect(t.title, isoName, reason: '메타데이터를 받으면 이름이 바뀜');
expect(t.progress, greaterThan(0));
expect(t.totalBytes, greaterThan(100 << 20));
expect(speeds, isNotEmpty, reason: '속도가 보임');
expect(find.text(isoName), findsWidgets, reason: '다운로드 화면 줄');
// 일시 정지: 받은 크기가 늘지 않는다
await tester.runAsync(() => d.pause([t]));
await wait(3000);
final paused = t.receivedBytes ?? 0;
final onDisk = t.files.where((f) => File(f).existsSync()).map((f) => File(f).lengthSync()).fold(0, (a, b) => a + b);
await wait(5000);
log('일시 정지: ${t.state} $paused');
expect(t.state, DownloadState.paused);
expect(t.receivedBytes ?? 0, paused);
expect(t.files.where((f) => File(f).existsSync()).map((f) => File(f).lengthSync()).fold(0, (a, b) => a + b), onDisk,
reason: '멈춘 동안 파일이 커지지 않음');
// 재개: 이어서 받는다 (처음부터가 아니라)
await tester.runAsync(() => d.resume([t]));
final resumed = await until(() => (t.receivedBytes ?? 0) >= paused + (4 << 20), within: const Duration(minutes: 3));
log('재개: ${t.state} ${t.receivedBytes}');
expect(resumed, isTrue, reason: '재개 뒤 4MB 더 받지 못함');
if (!forced) {
// 끝까지 · 공식 SHA256
final done = await until(() => t.state == DownloadState.done || t.state == DownloadState.failed,
within: const Duration(minutes: 30));
log('끝: ${t.state} ${t.files}');
expect(done && t.state == DownloadState.done, isTrue, reason: '${t.state} ${t.error}');
final iso = t.files.firstWhere((f) => f.endsWith(isoName));
final got = await tester.runAsync(() async => '${await sha256.bind(File(iso).openRead()).first}');
log('SHA256 $got');
expect(got, want, reason: '공식 SHA256SUMS 와 같음');
} else {
// 취소: 받던 파일 (와 .aria2) 이 지워진다
final files = [...t.files];
await tester.runAsync(() => d.cancel([t]));
await wait(2000);
log('취소: ${t.state} 남은 파일 ${files.where((f) => File(f).existsSync() || File('$f.aria2').existsSync()).toList()}');
expect(t.state, DownloadState.cancelled);
for (final f in files) {
expect(File(f).existsSync() || File('$f.aria2').existsSync(), isFalse, reason: '$f 지워짐');
}
}
await tester.runAsync(d.shutdown);
await tester.pumpWidget(const SizedBox());
await wait(500);
}, timeout: const Timeout(Duration(minutes: 45)));
}
}
/// .torrent (bencode) 에서 정보 해시 (info 사전 바이트의 SHA-1) 와 첫 tracker
(String, String) _torrentInfo(Uint8List b) {
var i = 0;
String str() {
final colon = b.indexOf(58, i); // ':'
final n = int.parse(ascii.decode(b.sublist(i, colon)));
final s = latin1.decode(b.sublist(colon + 1, colon + 1 + n));
i = colon + 1 + n;
return s;
}
void skip() {
final c = b[i];
if (c == 0x69) {
i = b.indexOf(0x65, i) + 1; // i…e
} else if (c == 0x6c || c == 0x64) {
i++;
while (b[i] != 0x65) {
if (c == 0x64) str();
skip();
}
i++;
} else {
str();
}
}
String? hash, announce;
i = 1; // 맨 위 사전 'd'
while (b[i] != 0x65) {
final key = str();
final start = i;
if (key == 'announce') {
announce = utf8.decode(latin1.encode(str()));
continue;
}
skip();
if (key == 'info') hash = '${sha1.convert(b.sublist(start, i))}';
}
return (hash!, announce ?? '');
}

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/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:jj_mkvmaker/ui/monitor_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 'package:jj_mkvmaker/ui/theme.dart';
import 'support/temp_dir.dart'; import 'support/temp_dir.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@ -227,7 +228,8 @@ void main() {
await tap(find.text('srcA').first); await tap(find.text('srcA').first);
await tap(find.text('dstB').last); await tap(find.text('dstB').last);
await tap(find.text('좌 → 우')); 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 tap(find.text('빈 폴더 지움 · 원본 폴더도 지움'));
await shot('rsync_move_confirm'); await shot('rsync_move_confirm');
await tap(find.widgetWithText(FilledButton, '이동')); 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')); final steps = File(p.join(Directory.systemTemp.path, 'jj_shell_steps.txt'));
if (steps.existsSync()) steps.deleteSync(); if (steps.existsSync()) steps.deleteSync();
void mark(String s) => steps.writeAsStringSync('$s\n', mode: FileMode.append, flush: true); 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 { testWidgets('DesktopShell', (tester) async {
mark('start'); mark('start');

View File

@ -1,16 +1,38 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
/// 관리자 지시 (10/19, 28 이 찾음): 통합 시험 · 캡처가 %TEMP%\jj_* 시험 폴더를 남겼다 - 시험 끝 (실패해도) 에 지운다. /// 관리자 지시 (10/19, 28 이 찾음): 통합 시험 · 캡처가 %TEMP%\jj_* 시험 폴더를 남겼다 - 시험 끝 (실패해도) 에 지운다.
/// [base] 가 있으면 그 아래 (짧은 경로가 필요한 캡처). 반드시 시험 (testWidgets · test) 안에서 부른다 (addTearDown). /// [base] 가 있으면 그 아래 (짧은 경로가 필요한 캡처). 반드시 시험 (testWidgets · test) 안에서 부른다 (addTearDown).
Directory testTempDir(String prefix, {String? base}) { /// [heldByApp]: 앱이 끝날 때까지 놓지 않는 폴더 (Edge 사용자 데이터 - Windows 판 플러그인에는 환경 닫기가 없다).
/// 이름 앞에 jj_held_ 를 붙이고, tool/test_temp_check.ps1 은 이것을 실패로 세지 않고 앱이 끝난 뒤 실행 폴더와 함께 지운다
Directory testTempDir(String prefix, {String? base, bool heldByApp = false}) {
final root = base == null || base.isEmpty ? Directory.systemTemp : (Directory(base)..createSync(recursive: true)); final root = base == null || base.isEmpty ? Directory.systemTemp : (Directory(base)..createSync(recursive: true));
final dir = root.createTempSync(prefix); final dir = root.createTempSync(heldByApp ? 'jj_held_$prefix' : prefix);
addTearDown(() => deleteTestTemp(dir)); addTearDown(() => deleteTestTemp(dir));
if (base == null || base.isEmpty) expectInTaggedTemp(dir);
return dir; 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) {
final tag = Platform.environment['JJ_TEST_TMP_TAG'] ?? '';
if (tag.isEmpty) return;
expect(dir.absolute.path.toLowerCase(), contains('t_jjrun_$tag'.toLowerCase()),
reason: 'TEMP 가 시험 실행 폴더로 오지 않음 (JJ_TEST_TMP_TAG=$tag)');
}
/// 시험 폴더 지우기 - Windows 에서 앱 · WebView · 백신이 파일을 잠깐 잡고 있으면 몇 번 다시 해 본다 (기다림은 실제 시간: 가짜 시계 밖). /// 시험 폴더 지우기 - Windows 에서 앱 · WebView · 백신이 파일을 잠깐 잡고 있으면 몇 번 다시 해 본다 (기다림은 실제 시간: 가짜 시계 밖).
/// 긴 경로는 \\?\ 로. 끝내 못 지우면 알리기만 하고 시험은 실패시키지 않는다 (tool/test_temp_check.ps1 이 남은 것을 잡는다) /// 긴 경로는 \\?\ 로. 끝내 못 지우면 알리기만 하고 시험은 실패시키지 않는다 (tool/test_temp_check.ps1 이 남은 것을 잡는다)
void deleteTestTemp(Directory dir) { void deleteTestTemp(Directory dir) {

View File

@ -27,7 +27,7 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized(); IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('DNS 강제 · Tor 화면 실제 연결', (tester) async { testWidgets('DNS 강제 · Tor 화면 실제 연결', (tester) async {
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_tor_real_').path; final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_tor_real_', heldByApp: true).path;
Directory(data).createSync(recursive: true); Directory(data).createSync(recursive: true);
ComponentStore.dataDirectory = data; ComponentStore.dataDirectory = data;
ComponentStore.shared = ComponentStore(data); ComponentStore.shared = ComponentStore(data);

View File

@ -43,7 +43,7 @@ void main() {
await c.init(); await c.init();
await c.addVideos([video]); await c.addVideos([video]);
}); });
c.settings.webViewDataDir = testTempDir('jj_webview_').path; c.settings.webViewDataDir = testTempDir('jj_webview_', heldByApp: true).path;
final v = c.videos.single; final v = c.videos.single;
// 같은 폴더의 movie.en.srt 가 자동으로 잡힘 // 같은 폴더의 movie.en.srt 가 자동으로 잡힘
final s = v.subtitles.firstWhere((x) => x.path != null && p.equals(x.path!, en)); final s = v.subtitles.firstWhere((x) => x.path != null && p.equals(x.path!, en));

View File

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

View File

@ -51,7 +51,7 @@ void main() {
await req.response.close(); await req.response.close();
}); });
final url = 'http://127.0.0.1:${server.port}/'; final url = 'http://127.0.0.1:${server.port}/';
final dataDir = testTempDir('jj_webview_').path; final dataDir = testTempDir('jj_webview_', heldByApp: true).path;
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings

View File

@ -1,7 +1,9 @@
import 'dart:io'; import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton, kPrimaryButton, PointerDeviceKind; import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton, kPrimaryButton, PointerDeviceKind;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
@ -24,6 +26,7 @@ void main() {
late Directory tmp; late Directory tmp;
late String left, right; late String left, right;
late AppController c; late AppController c;
final shotKey = GlobalKey();
setUp(() async { setUp(() async {
await i18n.apply('ko', save: false); await i18n.apply('ko', save: false);
@ -70,7 +73,11 @@ void main() {
t.view.physicalSize = const Size(1600, 900); t.view.physicalSize = const Size(1600, 900);
t.view.devicePixelRatio = 1; t.view.devicePixelRatio = 1;
addTearDown(t.view.reset); 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); await until(t, () => find.text('doc.txt').evaluate().isNotEmpty && find.text('r1').evaluate().isNotEmpty);
expect(find.text('doc.txt'), findsOneWidget); expect(find.text('doc.txt'), findsOneWidget);
} }
@ -104,6 +111,61 @@ void main() {
int feedback() => find.byIcon(Icons.drive_file_move_outline).evaluate().length; 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 { testWidgets('82: 8px 문턱 · 놓을 곳 강조 · 같은 창은 빨간 테두리 · 묻기 창 [이동] · [되돌리기]', (t) async {
await open(t); await open(t);
final doc = t.getCenter(find.text('doc.txt')); final doc = t.getCenter(find.text('doc.txt'));
@ -141,17 +203,15 @@ void main() {
await until(t, () => find.text('1개 항목을 놓았습니다').evaluate().isNotEmpty); await until(t, () => find.text('1개 항목을 놓았습니다').evaluate().isNotEmpty);
expect(find.text('1개 항목을 놓았습니다'), findsOneWidget); expect(find.text('1개 항목을 놓았습니다'), findsOneWidget);
expect(find.widgetWithText(FilledButton, '복사'), findsOneWidget, reason: '처음 고른 단추는 [복사]'); 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 tapReady(t, find.widgetWithText(FilledButton, '이동'), '이동 확인 창 [이동]');
await t.tap(find.widgetWithText(FilledButton, '이동'));
await until(t, () => find.text('되돌리기').evaluate().isNotEmpty); await until(t, () => find.text('되돌리기').evaluate().isNotEmpty);
expect(File(p.join(right, 'doc.txt')).existsSync(), isTrue); expect(File(p.join(right, 'doc.txt')).existsSync(), isTrue);
expect(File(p.join(left, 'doc.txt')).existsSync(), isFalse); expect(File(p.join(left, 'doc.txt')).existsSync(), isFalse);
// 4) [되돌리기] → 원래 자리로 (지금의 이동 확인 창) // 4) [되돌리기] → 원래 자리로 (지금의 이동 확인 창)
await t.tap(find.text('되돌리기')); await tapReady(t, find.text('되돌리기'), '[되돌리기]');
await until(t, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty); await tapReady(t, find.widgetWithText(FilledButton, '이동'), '되돌리기의 이동 확인 창 [이동]');
await t.tap(find.widgetWithText(FilledButton, '이동'));
await until(t, () => File(p.join(left, 'doc.txt')).existsSync() && !File(p.join(right, 'doc.txt')).existsSync()); 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(left, 'doc.txt')).readAsStringSync(), 'hello');
expect(File(p.join(right, 'doc.txt')).existsSync(), isFalse); expect(File(p.join(right, 'doc.txt')).existsSync(), isFalse);

View File

@ -0,0 +1,97 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/i18n_controller.dart';
import 'package:jj_mkvmaker/app/live_sync.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/sync_tools.dart' show toCygwinPath;
import 'package:jj_mkvmaker/l10n/tr.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/monitor_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
import 'support/temp_dir.dart';
/// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙 (merge 파일 - 안에 "H a.txt") 이 있는 지우기 포함 쌍 - 모니터링 카드의 빨간 줄
/// "자동으로 지우지 않습니다" · [지금 지우기 (확인)] 창 (FilterLimitNote · 체크) 을 412 · 960, ko · en · ja 로 찍는다.
/// 시험 폴더 안에서만 (창은 [나중에] - 지우지 않음).
/// 실행: flutter test integration_test\windows_lsync_p0_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_lsync_p0');
final rsync = p.join(Directory.current.path, 'third_party', 'rsync', 'windows', 'rsync.exe');
for (final lang in ['ko', 'en', 'ja']) {
for (final (w, h) in [(412, 900), (960, 600)]) {
testWidgets('미리 볼 수 없는 규칙의 쌍 $lang ${w}x$h', (t) async {
if (!File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음');
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false));
final tmp = testTempDir('jj_lsyncp0_shot_');
final src = p.join(tmp.path, 's'), dst = p.join(tmp.path, 'd');
for (final (dir, rel) in [(src, 'a.txt'), (src, 'b.txt'), (dst, 'a.txt'), (dst, 'old.log'), (dst, 'extra.txt')]) {
File(p.join(dir, rel))
..createSync(recursive: true)
..writeAsStringSync('$dir/$rel');
}
File(p.join(tmp.path, 'rules.txt')).writeAsStringSync('H a.txt\n');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..rsyncSource = 'custom'
..rsyncPath = rsync
..rsyncOptions = '-a --filter="merge ${toCygwinPath(p.join(tmp.path, 'rules.txt'))}"'
..liveSyncPairs = [LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true)];
final live = LiveSync(c)..start();
addTearDown(live.dispose);
final pair = c.settings.liveSyncPairs.single;
await t.runAsync(() => live.syncNow(pair));
expect(live.noAutoDelete[LiveSync.keyOf(pair)], isNotEmpty);
final shotKey = GlobalKey();
await t.pumpWidget(MaterialApp(
theme: buildTheme(),
debugShowCheckedModeBanner: false,
builder: (_, child) => RepaintBoundary(key: shotKey, child: child),
home: MonitorPage(c: c),
));
Future<void> settle([int n = 8]) async {
for (var i = 0; i < n; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 80)));
await t.pump(const Duration(milliseconds: 80));
}
}
Future<void> shot(String name) async {
await settle(3);
expect(t.takeException(), isNull, reason: '$name 넘침');
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final img = await t.runAsync(() => b.toImage());
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
Directory(out).createSync(recursive: true);
File(p.join(out, 'lsync_p0_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
}
await t.tap(find.text(tr('lsync (실시간 동기화)')));
await settle();
expect(find.text(tr('이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다')), findsOneWidget);
await shot('1_card');
await t.tap(find.text(tr('지금 지우기 (확인)')));
await settle(12);
expect(find.textContaining(tr('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)')), findsOneWidget);
await shot('2_now');
await t.tap(find.text(tr('나중에')));
await settle();
expect(File(p.join(dst, 'old.log')).existsSync() && File(p.join(dst, 'a.txt')).existsSync(), isTrue, reason: '찍기만 - 지우지 않음');
});
}
}
}

View File

@ -0,0 +1,92 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/download_manager.dart';
import 'package:jj_mkvmaker/app/i18n_controller.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/download_detect.dart';
import 'package:jj_mkvmaker/l10n/tr.dart';
import 'package:jj_mkvmaker/services/downloader.dart';
import 'package:jj_mkvmaker/ui/downloads_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
/// 이름을 바꾸지 않는 가짜 백엔드 (아무것도 받지 않음 - 줄의 상태는 시험이 정한다)
class _Quiet implements DownloadBackend {
@override
final DownloadKind kind;
_Quiet(this.kind);
@override
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => null;
@override
Future<void> start(DownloadTask t, void Function() changed) async {
t
..state = DownloadState.downloading
..progress = null;
changed();
}
@override
Future<void> pause(DownloadTask t) async => t.state = DownloadState.paused;
@override
Future<void> cancel(DownloadTask t) async => t.state = DownloadState.cancelled;
@override
Future<void> shutdown() async {}
}
/// 관리자 지시 (10/20, 28 확인): 마그넷 줄 - 메타데이터 전에도 dn 이름 · 멈춘 줄의 막대는 움직이지 않고 주황 "멈춤".
/// 크기 412 · 960, 언어 ko · en · ja 로 찍는다 (가짜 백엔드 - 아무것도 받지 않음).
/// 실행: flutter test integration_test\windows_magnet_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_magnet');
const h = 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef0123456';
for (final lang in ['ko', 'en', 'ja']) {
for (final (w, hh) in [(412, 900), (960, 600)]) {
testWidgets('마그넷 줄 $lang ${w}x$hh', (t) async {
t.view.physicalSize = Size(w.toDouble(), hh.toDouble());
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false));
final d = DownloadManager(
backends: [_Quiet(DownloadKind.video), _Quiet(DownloadKind.torrent)],
settings: () => AppSettings()..downloadRoot = r'D:\dl',
readClipboard: () async => null,
);
// ① 메타데이터를 받는 중 (dn 이름 · 움직이는 "준비 중…") ② 멈춤 (이유 후보 · [다시 시도]) ③ 이름이 없는 마그넷
d.add('${h}1&dn=${Uri.encodeQueryComponent('[시험] 아주 긴 이름의 토렌트 묶음 - Big Buck Bunny 1080p (2008) 한글 자막 포함')}&tr=udp%3A%2F%2Ft.example%3A80');
d.add('${h}2&dn=Ubuntu+24.04.1+Desktop+amd64&tr=udp%3A%2F%2Ft.example%3A80');
d.add('${h}3');
final stalled = d.tasks.firstWhere((x) => x.title.startsWith('Ubuntu'));
stalled.extra
..['stalled'] = true
..['stallSince'] = DateTime.now().millisecondsSinceEpoch - 75000
..['stallReasons'] = ['dns', 'firewall'];
d.useSystemDns = () async {};
final shotKey = GlobalKey();
await t.pumpWidget(RepaintBoundary(
key: shotKey,
child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: DownloadsPage(d: d)),
));
for (var i = 0; i < 6; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
await t.pump(const Duration(milliseconds: 60));
}
expect(t.takeException(), isNull, reason: '넘침');
expect(find.textContaining('[시험] 아주 긴 이름'), findsOneWidget);
expect(find.textContaining('Ubuntu 24.04.1 Desktop amd64'), findsOneWidget);
expect(find.text(tr('멈춤 (시작 안 됨)')), findsOneWidget);
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final img = await t.runAsync(() => b.toImage());
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
Directory(out).createSync(recursive: true);
File(p.join(out, 'magnet_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
});
}
}
}

View File

@ -0,0 +1,79 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/i18n_controller.dart';
import 'package:jj_mkvmaker/core/sync_tools.dart';
import 'package:jj_mkvmaker/ui/sync_preview_view.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
/// 관리자 지시 (10/20, 28 지적): 알아볼 수 없는 옵션 안내 - 끝 문구가 문제 종류에 맞는지, 412 에서 넘치지 않는지.
/// 확인 창의 빨간 글 (OptionProblemNote) 과 설정 칸 아래 글 (errorText, 3줄까지) 을 크기 412 · 960, 언어 ko · en · ja 로 찍는다.
/// 실행: flutter test integration_test\windows_option_problem_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_optproblem');
const cases = [
'-a --delete --exclude="a.tmp --exclude=keep.txt', // 닫히지 않은 따옴표
'-a delete', // 경로로 읽힘
'-a --delete --exclude', // 값 없음
'-a ---delete «x»', // 이상한 글자
'-av delete --exclude', // 여러 종류
];
for (final lang in ['ko', 'en', 'ja']) {
for (final (w, hh) in [(412, 900), (960, 600)]) {
testWidgets('옵션 안내 $lang ${w}x$hh', (t) async {
t.view.physicalSize = Size(w.toDouble(), hh.toDouble());
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false));
final shotKey = GlobalKey();
await t.pumpWidget(RepaintBoundary(
key: shotKey,
child: MaterialApp(
theme: buildTheme(),
debugShowCheckedModeBanner: false,
home: Scaffold(
body: ListView(padding: const EdgeInsets.all(16), children: [
for (final o in cases) ...[
Text(o, style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
OptionProblemNote(optionProblems(o)),
const SizedBox(height: 8),
],
// 설정 칸 (copy_sync_settings 의 rsync 옵션 칸과 같은 폭 · 글 줄 수)
SizedBox(
width: 380,
child: TextField(
controller: TextEditingController(text: cases.last),
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
errorText: optionProblemText(optionProblems(cases.last)),
errorMaxLines: 3,
),
),
),
]),
),
),
));
for (var i = 0; i < 4; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
await t.pump(const Duration(milliseconds: 60));
}
expect(t.takeException(), isNull, reason: '넘침');
expect(find.byType(OptionProblemNote), findsNWidgets(cases.length));
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final img = await t.runAsync(() => b.toImage());
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
Directory(out).createSync(recursive: true);
File(p.join(out, 'optproblem_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
});
}
}
}

View File

@ -0,0 +1,56 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/i18n_controller.dart';
import 'package:jj_mkvmaker/l10n/tr.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/settings_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
/// 관리자 판단 (10/20): 환경 설정 > 파일 탐색기의 "원격 서버 응답 기다리기" (30 · 60 · 120 · 300초) 를
/// 412 · 960, ko · en · ja 로 찍는다 (설정은 바꾸지 않음 - 시험 안의 설정만).
/// 실행: flutter test integration_test\windows_remote_idle_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_remote_idle');
for (final lang in ['ko', 'en', 'ja']) {
for (final (w, h) in [(412, 900), (960, 600)]) {
testWidgets('원격 서버 응답 기다리기 $lang ${w}x$h', (t) async {
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false));
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final key = GlobalKey();
await t.pumpWidget(MaterialApp(
theme: buildTheme(),
debugShowCheckedModeBanner: false,
home: RepaintBoundary(key: key, child: SettingsPage(c: c)),
));
await t.pump(const Duration(milliseconds: 300));
final tile = find.text(tr('원격 서버 응답 기다리기'));
await t.scrollUntilVisible(tile, 300,
scrollable: find.byWidgetPredicate((x) => x is Scrollable && x.axisDirection == AxisDirection.down).first, maxScrolls: 400);
await t.pump(const Duration(milliseconds: 300));
await t.ensureVisible(tile);
await t.pump(const Duration(milliseconds: 300));
expect(t.takeException(), isNull, reason: '넘침');
expect(find.text(trf('{0}초 (기본)', [60])), findsOneWidget);
final b = key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final img = await t.runAsync(() => b.toImage());
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
Directory(out).createSync(recursive: true);
File(p.join(out, 'remote_idle_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
});
}
}
}

View File

@ -0,0 +1,98 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/i18n_controller.dart';
import 'package:jj_mkvmaker/l10n/tr.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
import 'support/temp_dir.dart';
/// 28 확인 (10/20): 좁은 Rsync 화면의 [오른쪽] 탭을 처음 볼 때 그 창의 지금 폴더가 보이는지 · 확인 창의 체크 제목
/// "(--remove-source-files)" 가 "(--" 에서 갈리지 않는지를 412 · 960, ko · en · ja 로 찍는다 (시험 폴더 안에서만, [취소]).
/// 실행: flutter test integration_test\windows_rsync_narrow_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_rsync_narrow');
for (final lang in ['ko', 'en', 'ja']) {
for (final (w, h) in [(412, 900), (960, 600)]) {
testWidgets('좁은 Rsync 화면 $lang ${w}x$h', (t) async {
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false));
final tmp = testTempDir('jj_rsnarrow_shot_');
// 맨 위에 폴더가 많고 (C: 처럼) 두 창의 지금 폴더는 깊은 곳
for (var i = 0; i < 60; i++) {
Directory(p.join(tmp.path, 'a${i.toString().padLeft(2, '0')}')).createSync();
}
final l = p.join(tmp.path, 'zz', 'deep', 'L'), r = p.join(tmp.path, 'zz', 'deep', 'R');
File(p.join(l, 'sub', 'a.txt'))
..createSync(recursive: true)
..writeAsStringSync('a');
Directory(p.join(r, 'rsub')).createSync(recursive: true);
for (var i = 0; i < 20; i++) {
Directory(p.join(tmp.path, 'zz${i.toString().padLeft(2, '0')}')).createSync();
}
ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')];
addTearDown(() => ExplorerPage.debugVolumes = null);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerOrientation = 'side'
..rsyncPaths = [l, r];
final shotKey = GlobalKey();
await t.pumpWidget(MaterialApp(
theme: buildTheme(),
debugShowCheckedModeBanner: false,
builder: (_, child) => RepaintBoundary(key: shotKey, child: child),
home: ExplorerPage(c: c, rsync: true),
));
Future<void> settle([int n = 10]) async {
for (var i = 0; i < n; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
await t.pump(const Duration(milliseconds: 60));
}
}
Future<void> shot(String name) async {
await settle(3);
expect(t.takeException(), isNull, reason: '$name 넘침');
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final img = await t.runAsync(() => b.toImage());
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
Directory(out).createSync(recursive: true);
File(p.join(out, 'rsync_narrow_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
}
await settle(20);
final narrow = find.text('${tr('오른쪽')} · R').evaluate().isNotEmpty;
await t.tap(find.text('sub').hitTestable());
await settle();
if (narrow) {
await t.tap(find.text('${tr('오른쪽')} · R'));
await settle(15);
expect(find.text('rsub').hitTestable(), findsOneWidget, reason: '[오른쪽] 탭을 처음 보면 지금 폴더');
await shot('1_right_tab');
}
await t.tap(find.text('rsub').hitTestable());
await settle();
await t.tap(find.text(tr('좌 → 우')).first);
await settle(25);
await shot('2_confirm');
await t.tap(find.text(tr('취소')).last);
await settle();
expect(File(p.join(l, 'sub', 'a.txt')).existsSync(), isTrue, reason: '찍기만 - 실행하지 않음');
});
}
}
}

View File

@ -33,6 +33,7 @@ void main() {
await t.runAsync(() => i18n.apply(lang, save: false)); await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false)); addTearDown(() => i18n.apply('ko', save: false));
final tmp = testTempDir('jj_xpshot_'); final tmp = testTempDir('jj_xpshot_');
appTempInto(tmp); // 앱 임시 폴더 (jj_mkvmaker) 도 이 아래로 - 끝에 함께 지움 (관리자 지시 10/20)
final a = Directory(p.join(tmp.path, 'Videos'))..createSync(); final a = Directory(p.join(tmp.path, 'Videos'))..createSync();
final b = Directory(p.join(tmp.path, 'Backup'))..createSync(); final b = Directory(p.join(tmp.path, 'Backup'))..createSync();
for (final n in ['Show S01', 'Movies']) { for (final n in ['Show S01', 'Movies']) {
@ -74,6 +75,13 @@ void main() {
} }
await settle(20); 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 펼치기 · 즐겨찾기 펼치기 // Show S01 펼치기 · 즐겨찾기 펼치기
await t.tap(find.text('Show S01').first); await t.tap(find.text('Show S01').first);
await settle(); await settle();

View File

@ -31,7 +31,9 @@ import '../services/model_store.dart';
import '../services/platform_services.dart'; import '../services/platform_services.dart';
import '../services/system_usage.dart'; import '../services/system_usage.dart';
import 'settings.dart'; import 'settings.dart';
import 'cleanup.dart' show removeUpdateLeftovers;
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import '../core/remote_idle.dart';
extension<T> on Set<T> { extension<T> on Set<T> {
Set<T> ifEmpty(Set<T> other) => isEmpty ? other : this; Set<T> ifEmpty(Set<T> other) => isEmpty ? other : this;
@ -158,6 +160,8 @@ class AppController extends ChangeNotifier {
SftpRegistry.configure(settings.sftpServers); SftpRegistry.configure(settings.sftpServers);
// 88: Android SMB 서버 (smb:// 경로) // 88: Android SMB 서버 (smb:// 경로)
SmbRegistry.requireSecure = settings.smbRequireSecure; SmbRegistry.requireSecure = settings.smbRequireSecure;
// 관리자 판단 (10/20): 원격 서버 받기 · 올리기의 조각 사이 기다리기
RemoteIdle.limit = Duration(seconds: settings.remoteIdleSeconds);
SmbRegistry.configure(settings.smbServers); SmbRegistry.configure(settings.smbServers);
FtpRegistry.configure(settings.ftpServers); FtpRegistry.configure(settings.ftpServers);
outputRootOverride = outputRootOverride =
@ -205,6 +209,10 @@ class AppController extends ChangeNotifier {
); );
_applySettings(); _applySettings();
} }
if (Platform.isWindows) {
final gone = removeUpdateLeftovers(Directory.systemTemp.path);
if (gone.isNotEmpty) _log(trf('지난 업데이트가 남긴 임시 파일을 지움: {0}', [gone.join(', ')]));
}
ffmpegVersion = await _tool.version(); ffmpegVersion = await _tool.version();
if (ffmpegVersion == null) { if (ffmpegVersion == null) {
_log(tr('⚠ FFmpeg 를 찾을 수 없습니다. MKV 만들기를 사용할 수 없습니다.')); _log(tr('⚠ FFmpeg 를 찾을 수 없습니다. MKV 만들기를 사용할 수 없습니다.'));

View File

@ -7,6 +7,29 @@ import '../l10n/tr.dart';
import 'app_controller.dart'; import 'app_controller.dart';
import 'download_manager.dart'; import 'download_manager.dart';
/// 관리자 지시 (10/20): Windows 업데이트가 %TEMP% 에 남긴 것 - 풀어 둔 새 판 (jj_mkvmaker_update_<판>, 수백 MB) ·
/// 교체 스크립트 (jj_mkvmaker_update.ps1) · 기록 (jj_mkvmaker_update.log). 예전에는 [정리] 를 누르기 전까지 남았다.
/// 앱이 켜질 때 부른다. 교체 스크립트가 아직 끝나는 중일 수 있어 [minAge] 보다 오래된 것만 지운다. 지운 이름을 돌려준다
List<String> removeUpdateLeftovers(String sysTmp, {Duration minAge = const Duration(minutes: 2), DateTime? now}) {
final mine = RegExp(r'^jj_mkvmaker_update(_.+|\.ps1|\.log)$');
final before = (now ?? DateTime.now()).subtract(minAge);
final removed = <String>[];
try {
for (final e in Directory(sysTmp).listSync(followLinks: false)) {
final name = p.basename(e.path);
if (!mine.hasMatch(name)) continue;
try {
if (!e.statSync().modified.isBefore(before)) continue;
e.deleteSync(recursive: true);
removed.add(name);
} catch (_) {
// 잡혀 있으면 (기록을 메모장으로 열어 둔 것 등) 다음에
}
}
} catch (_) {}
return removed;
}
/// 정리할 파일 · 폴더 하나 /// 정리할 파일 · 폴더 하나
class CleanupItem { class CleanupItem {
final String path; final String path;

View File

@ -326,6 +326,21 @@ class DownloadManager extends ChangeNotifier {
_changed(); _changed();
} }
/// 관리자 지시 (10/20): 시작되지 않는 마그넷 · 토렌트 (받은 것 없음) 를 처음부터 다시 - 지금 것을 지우고 새로 넣는다
Future<void> retryStalled(DownloadTask t) async {
await backends[t.kind]!.cancel(t);
t.extra
..remove('gid')
..remove('stallSince')
..remove('stalled')
..remove('stallReasons');
t.files.clear();
t
..state = DownloadState.queued
..error = null;
_changed();
}
/// 중지하고 받던 파일 삭제 (목록에는 '취소됨' 으로 남김) /// 중지하고 받던 파일 삭제 (목록에는 '취소됨' 으로 남김)
Future<void> cancel(Iterable<DownloadTask> list) async { Future<void> cancel(Iterable<DownloadTask> list) async {
for (final t in list.toList()) { for (final t in list.toList()) {

View File

@ -17,6 +17,7 @@ import '../platform/windows/rsync_installer.dart';
import 'app_controller.dart'; import 'app_controller.dart';
import 'settings.dart'; import 'settings.dart';
import '../core/rsync_filter.dart'; import '../core/rsync_filter.dart';
import '../core/rsync_launch.dart';
/// 앱에 들어 있는 rsync (없으면 null). 시작할 때 [findBundledRsync] 로 정한다. /// 앱에 들어 있는 rsync (없으면 null). 시작할 때 [findBundledRsync] 로 정한다.
/// - Windows: 실행 파일 옆 rsync/rsync.exe (tool/fetch_rsync.ps1 로 준비해 빌드에 넣음) /// - Windows: 실행 파일 옆 rsync/rsync.exe (tool/fetch_rsync.ps1 로 준비해 빌드에 넣음)
@ -178,6 +179,34 @@ class LiveSync extends ChangeNotifier {
/// 확인한 뒤 옵션이 바뀌어 다시 묻는 쌍 (확인 창에 그 이유를 보인다) /// 확인한 뒤 옵션이 바뀌어 다시 묻는 쌍 (확인 창에 그 이유를 보인다)
final reasked = <String>{}; final reasked = <String>{};
/// P0 (관리자 3a 10/20, 28 재현): 지우기 포함 rsync 쌍의 옵션에 미리 볼 수 없는 거르기 규칙 (-,s · H · P · merge · --files-from 등)
/// 이 있으면 실제 rsync 가 지울 목록에 없던 것까지 지울 수 있다 ("-,s a.txt" · "H a.txt" 는 받는 쪽 a.txt 를 지움).
/// 저절로 도는 맞추기는 지우지 않고 보내기만 하고, 지우기는 카드의 [지금 지우기 (확인)] 로 한 번씩만. 값은 그 규칙들
final noAutoDelete = <String, List<String>>{};
final _deleteOnce = <String>{};
/// 이 쌍의 지우기에 걸리는 미리 볼 수 없는 거르기 규칙 (지우기 포함 rsync 쌍만)
List<String> unsupportedOf(LiveSyncPair x) => x.delete ? _filterOf(x).unsupported : const [];
/// 미리 볼 수 없는 규칙이 있는 쌍의 "맞출 것" 은 지우기 없이 센다 (저절로 지우지 않으므로 − 줄이 늘 남지 않게)
LiveSyncPair _pendingPair(LiveSyncPair x) => unsupportedOf(x).isNotEmpty ? x.copyWith(delete: false) : x;
/// [지금 지우기] 창에 보일 지울 것 (미리 본 것 - 위 규칙 때문에 실제와 다를 수 있음) · 그 가운데 --delete-excluded 로 지워지는 것
Future<(List<String>, List<String>)> previewDeletes(LiveSyncPair x) async {
final excl = <String>[];
final del = [
for (final d in await diff(x.copyWith(delete: true), limit: 100000, filter: _filterOf(x), excluded: excl))
if (d.startsWith('− ')) d.substring(2)
];
return (del, excl);
}
/// 확인 창 (FilterLimitNote 와 체크) 을 거친 뒤 이번 한 번만 지우기 포함으로 맞춘다. 다음 자동 맞추기는 다시 보내기만
Future<void> deleteNow(LiveSyncPair x) async {
_deleteOnce.add(keyOf(x));
await syncNow(x);
}
/// 받는 쪽에만 있는 [path] (상대 경로 [r]) 가 지워지는 모양을 [out] 에 "− 경로" 로 (rsync · _mirrorV 와 같게): /// 받는 쪽에만 있는 [path] (상대 경로 [r]) 가 지워지는 모양을 [out] 에 "− 경로" 로 (rsync · _mirrorV 와 같게):
/// 거른 것은 지우지 않고 (--delete-excluded 면 지움), 안에 거른 것이 든 폴더는 남기고 그 안의 거르지 않은 것만. /// 거른 것은 지우지 않고 (--delete-excluded 면 지움), 안에 거른 것이 든 폴더는 남기고 그 안의 거르지 않은 것만.
/// 10/20 (15, 28 의 장면으로 찾음): 예전에는 그런 폴더도 "− 폴더" 한 줄이라 지울지 묻는 창에 폴더가 통째로 보이고, /// 10/20 (15, 28 의 장면으로 찾음): 예전에는 그런 폴더도 "− 폴더" 한 줄이라 지울지 묻는 창에 폴더가 통째로 보이고,
@ -186,23 +215,20 @@ class LiveSync extends ChangeNotifier {
static Future<void> _gone(String path, String r, bool isDir, RsyncFilter filter, List<String> out, int limit, static Future<void> _gone(String path, String r, bool isDir, RsyncFilter filter, List<String> out, int limit,
[List<String>? excluded]) async { [List<String>? excluded]) async {
if (out.length >= limit) return; if (out.length >= limit) return;
if (filter.excluded(r, isDir: isDir)) { // 관리자 지시 (10/20): 받는 쪽 규칙 (- · P · R · ,r - --delete-excluded 면 쪽을 적지 않은 규칙은 빠짐) 에 걸린 것만 지킨다
if (!filter.deleteExcluded) return; if (filter.protects(r, isDir: isDir)) return;
out.add('− $r'); Future<void> mark() async {
excluded?.add(r); // --delete-excluded 때문에 지워지는 것: 보내는 쪽에서 거른 것 · 그런 것이 든 폴더
return; if (excluded != null &&
filter.deleteExcluded &&
(filter.excluded(r, isDir: isDir) || (isDir && await _hasExcluded(path, r, filter, sender: true)))) {
excluded.add(r);
}
} }
if (!isDir || filter.isEmpty) {
out.add('− $r'); if (!isDir || !filter.protectsAny || !await _hasExcluded(path, r, filter)) {
return;
}
if (filter.deleteExcluded) {
out.add('− $r');
if (excluded != null && await _hasExcluded(path, r, filter)) excluded.add(r);
return;
}
if (!await _hasExcluded(path, r, filter)) {
out.add('− $r'); out.add('− $r');
await mark();
return; return;
} }
List<({String path, bool isDir, int size, DateTime modified})> items; List<({String path, bool isDir, int size, DateTime modified})> items;
@ -218,8 +244,8 @@ class LiveSync extends ChangeNotifier {
} }
} }
/// 폴더 [dir] (상대 경로 [rel]) 안에 거른 것이 하나라도 있는지 /// 폴더 [dir] (상대 경로 [rel]) 안에 받는 쪽에서 지키는 것 ([sender] 면 보내는 쪽에서 거른 것) 이 하나라도 있는지
static Future<bool> _hasExcluded(String dir, String rel, RsyncFilter filter) async { static Future<bool> _hasExcluded(String dir, String rel, RsyncFilter filter, {bool sender = false}) async {
List<({String path, bool isDir, int size, DateTime modified})> items; List<({String path, bool isDir, int size, DateTime modified})> items;
try { try {
items = await vList(dir); items = await vList(dir);
@ -228,8 +254,8 @@ class LiveSync extends ChangeNotifier {
} }
for (final e in items) { for (final e in items) {
final r = '$rel/${vBasename(e.path)}'; final r = '$rel/${vBasename(e.path)}';
if (filter.excluded(r, isDir: e.isDir)) return true; if (sender ? filter.excluded(r, isDir: e.isDir) : filter.protects(r, isDir: e.isDir)) return true;
if (e.isDir && await _hasExcluded(e.path, r, filter)) return true; if (e.isDir && await _hasExcluded(e.path, r, filter, sender: sender)) return true;
} }
return false; return false;
} }
@ -345,8 +371,8 @@ class LiveSync extends ChangeNotifier {
problems[k] = SourceUnreadableException(x.source, cause: e); problems[k] = SourceUnreadableException(x.source, cause: e);
} }
try { try {
pending[k] = await diff(x, filter: _filterOf(x)); pending[k] = await diff(_pendingPair(x), filter: _filterOf(x));
await _countOnlyTarget(x); await _countOnlyTarget(_pendingPair(x));
} catch (_) {} } catch (_) {}
notifyListeners(); notifyListeners();
} }
@ -485,9 +511,15 @@ class LiveSync extends ChangeNotifier {
return null; return null;
} }
/// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 rsync 옵션 (있으면 맞추지 않는다 - 카드에 빨갛게)
List<OptionProblem> unreadableOf(LiveSyncPair x) => optionProblems(_optionsOf(x));
Future<void> syncNow(LiveSyncPair x) async { Future<void> syncNow(LiveSyncPair x) async {
final k = keyOf(x); final k = keyOf(x);
final nest = nestingProblem(x, allowInnerToOuter: c.settings.allowInnerToOuter); final bad = unreadableOf(x);
final nest = bad.isNotEmpty
? optionProblemMessage(bad)
: nestingProblem(x, allowInnerToOuter: c.settings.allowInnerToOuter);
if (nest != null) { if (nest != null) {
status[k] = (DateTime.now(), nest); status[k] = (DateTime.now(), nest);
notifyListeners(); notifyListeners();
@ -503,8 +535,16 @@ class LiveSync extends ChangeNotifier {
final asked = x; final asked = x;
try { try {
// 42: 지우기를 아직 확인하지 않았으면, 지울 것이 있을 때 지우지 않고 맞추고 확인을 기다린다 (목록은 [toDelete]) // 42: 지우기를 아직 확인하지 않았으면, 지울 것이 있을 때 지우지 않고 맞추고 확인을 기다린다 (목록은 [toDelete])
var ask = x.delete && !x.deleteConfirmed; final once = _deleteOnce.remove(k);
if (x.delete && x.deleteConfirmed && x.confirmedOptions != _optionsOf(x)) { final limits = unsupportedOf(x);
if (limits.isEmpty) {
noAutoDelete.remove(k);
} else {
noAutoDelete[k] = limits;
}
// 미리 볼 수 없는 규칙이 있으면 묻지도 않는다 (묻는 목록이 실제와 달라 - 28: 묻는 목록 밖의 a.txt 가 지워짐 · P 는 지킨 것을 끝없이 물음)
var ask = limits.isEmpty && x.delete && !x.deleteConfirmed;
if (limits.isEmpty && x.delete && x.deleteConfirmed && x.confirmedOptions != _optionsOf(x)) {
if (await _deletesGrew(x)) { if (await _deletesGrew(x)) {
ask = true; ask = true;
reasked.add(k); reasked.add(k);
@ -534,6 +574,7 @@ class LiveSync extends ChangeNotifier {
toDeleteExcluded.remove(k); toDeleteExcluded.remove(k);
reasked.remove(k); reasked.remove(k);
} }
if (limits.isNotEmpty && !once) x = x.copyWith(delete: false); // 보내기만
// 지우기 포함이면 원본을 확실히 읽을 수 있을 때만 (rsync --delete · robocopy /PURGE 도). 못 읽으면 멈추고 알린다 // 지우기 포함이면 원본을 확실히 읽을 수 있을 때만 (rsync --delete · robocopy /PURGE 도). 못 읽으면 멈추고 알린다
final allowEmpty = _allowEmpty.remove(k); final allowEmpty = _allowEmpty.remove(k);
if (x.delete) await ensureSourceForDelete(x.source, x.target, allowEmpty: allowEmpty); if (x.delete) await ensureSourceForDelete(x.source, x.target, allowEmpty: allowEmpty);
@ -553,8 +594,8 @@ class LiveSync extends ChangeNotifier {
: (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n])); : (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n]));
problems.remove(k); problems.remove(k);
emptyDeletes.remove(k); emptyDeletes.remove(k);
pending[k] = await diff(asked, filter: _filterOf(asked)); pending[k] = await diff(_pendingPair(asked), filter: _filterOf(asked));
await _countOnlyTarget(asked); await _countOnlyTarget(_pendingPair(asked));
if (n != 0) c.note(trf('실시간 동기화 ({0}): {1} → {2}', [method.label, x.source, x.target])); if (n != 0) c.note(trf('실시간 동기화 ({0}): {1} → {2}', [method.label, x.source, x.target]));
} on SourceUnreadableException catch (e) { } on SourceUnreadableException catch (e) {
// 68 · 70: 대상은 건드리지 않고 멈춤. 카드에 빨갛게 · 작업 알림에 · 로그에 // 68 · 70: 대상은 건드리지 않고 멈춤. 카드에 빨갛게 · 작업 알림에 · 로그에
@ -590,7 +631,15 @@ class LiveSync extends ChangeNotifier {
if (exe == null) throw StateError(tr('rsync 가 없습니다 (환경 설정 > 파일 탐색기에서 내려받기 · 경로 지정)')); if (exe == null) throw StateError(tr('rsync 가 없습니다 (환경 설정 > 파일 탐색기에서 내려받기 · 경로 지정)'));
final args = rsyncArgsFor(x, s, windows: Platform.isWindows); final args = rsyncArgsFor(x, s, windows: Platform.isWindows);
await Directory(x.target).create(recursive: true); await Directory(x.target).create(recursive: true);
final r = await Process.run(exe, args, stdoutEncoding: utf8, stderrEncoding: utf8); // P0 (10/20): Windows 의 rsync 는 응답 파일 · MSYS=noglob 으로 (' · * 를 런타임이 다시 읽지 않게 - [prepareRsyncLaunch])
final launch = await prepareRsyncLaunch(args);
final ProcessResult r;
try {
r = await Process.run(exe, launch.args,
workingDirectory: launch.workingDirectory, environment: launch.environment, stdoutEncoding: utf8, stderrEncoding: utf8);
} finally {
launch.dispose();
}
if (!rsyncOk(r.exitCode)) throw ProcessException(exe, const [], '${r.stderr}'.trim(), r.exitCode); if (!rsyncOk(r.exitCode)) throw ProcessException(exe, const [], '${r.stderr}'.trim(), r.exitCode);
return RsyncOutput().feed('${r.stdout}\n').length; return RsyncOutput().feed('${r.stdout}\n').length;
} }
@ -599,8 +648,11 @@ class LiveSync extends ChangeNotifier {
/// 41 · 42 의 확인을 건너뛰지 않게 뺀다 (97) /// 41 · 42 의 확인을 건너뛰지 않게 뺀다 (97)
static List<String> rsyncArgsFor(LiveSyncPair x, AppSettings s, {required bool windows}) => [ static List<String> rsyncArgsFor(LiveSyncPair x, AppSettings s, {required bool windows}) => [
// 107: 원본을 지우는 옵션 (--remove-source-files) 도 실시간 동기화에서는 쓰지 않는다 // 107: 원본을 지우는 옵션 (--remove-source-files) 도 실시간 동기화에서는 쓰지 않는다
...splitOptions(s.rsyncOptions) // 관리자 판단 (10/20, 28 확인): 목록 · 기록 · 배치 파일의 C:\… 는 rsync 가 읽는 꼴로 (rsyncFileOptionArgs)
.where((o) => o != '-P' && o != '--progress' && !isRsyncDeleteOption(o) && !isSourceRemovingOption(o)), ...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 (!windows) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음)
if (s.copyBandwidthKBps > 0) '--bwlimit=${s.copyBandwidthKBps}', if (s.copyBandwidthKBps > 0) '--bwlimit=${s.copyBandwidthKBps}',
if (x.delete) '--delete', if (x.delete) '--delete',
@ -609,7 +661,7 @@ class LiveSync extends ChangeNotifier {
if (x.delete && splitOptions(s.rsyncOptions).contains('--delete-excluded')) '--delete-excluded', if (x.delete && splitOptions(s.rsyncOptions).contains('--delete-excluded')) '--delete-excluded',
...appTempRsyncExcludes, // 78 보충: 이름 바꾸는 중의 임시 이름은 보내지도 지우지도 않는다 ...appTempRsyncExcludes, // 78 보충: 이름 바꾸는 중의 임시 이름은 보내지도 지우지도 않는다
'${toCygwinPath(x.source, windows: windows)}/', '${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 /// robocopy 인수 (실시간 동기화). 지우기 (/MIR · /PURGE) 는 쌍의 "지우기 포함" 으로만 (/MIR 는 /E 로) - 97

View File

@ -19,6 +19,7 @@ import '../services/image_ai.dart' show AiService;
import '../services/app_shell.dart' show appIconOf; import '../services/app_shell.dart' show appIconOf;
import '../services/secret_store.dart'; import '../services/secret_store.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import '../core/remote_idle.dart';
/// MKV 세부 정보의 이동 버튼 하나: 표시 이름 · 옮길 폴더 /// MKV 세부 정보의 이동 버튼 하나: 표시 이름 · 옮길 폴더
class MoveTarget { class MoveTarget {
@ -295,6 +296,9 @@ class AppSettings {
/// 다운로드가 끝난 동영상을 편집 목록(MKV 만들기)에 자동으로 추가 /// 다운로드가 끝난 동영상을 편집 목록(MKV 만들기)에 자동으로 추가
bool addFinishedDownloads = true; bool addFinishedDownloads = true;
/// 마그넷 · 토렌트가 이 초 동안 메타데이터 · 피어를 못 받으면 그 줄에 이유 후보 · [다시 시도] (0 = 알리지 않음, 관리자 지시 10/20)
int torrentStallSeconds = 60;
/// OpenSubtitles (인터넷 자막 검색) - 키는 필수, 아이디·비밀번호는 선택 (받기 횟수 늘어남) /// OpenSubtitles (인터넷 자막 검색) - 키는 필수, 아이디·비밀번호는 선택 (받기 횟수 늘어남)
String openSubtitlesKey = ''; String openSubtitlesKey = '';
String openSubtitlesUser = ''; String openSubtitlesUser = '';
@ -618,6 +622,10 @@ class AppSettings {
/// l: FTP · FTPS 서버. 비밀번호는 안전 저장소에만 (설정 파일에는 평문으로 쓰지 않는다) /// l: FTP · FTPS 서버. 비밀번호는 안전 저장소에만 (설정 파일에는 평문으로 쓰지 않는다)
List<FtpServer> ftpServers = []; List<FtpServer> ftpServers = [];
/// 관리자 판단 (10/20): 원격 서버 (FTP · SFTP · SMB · WebDAV) 받기 · 올리기에서 조각 사이를 기다리는 시간 (초).
/// 이 시간 동안 아무것도 오가지 않으면 실패 ("서버가 답하지 않습니다"). 30 · 60 (처음) · 120 · 300 - 그 밖의 값은 60
int remoteIdleSeconds = RemoteIdle.defaultSeconds;
// ───────── 123 · 121: AI 그림 · 해상도 올리기 ───────── // ───────── 123 · 121: AI 그림 · 해상도 올리기 ─────────
/// 그리는 엔진: 'local' 이 기기 안 (기본) · 아니면 [aiServices] 의 id (사용자가 추가한 서버 · 서비스) /// 그리는 엔진: 'local' 이 기기 안 (기본) · 아니면 [aiServices] 의 id (사용자가 추가한 서버 · 서비스)
@ -761,6 +769,7 @@ class AppSettings {
'showHotkey': showHotkey, 'showHotkey': showHotkey,
'askAiOptions': askAiOptions, 'askAiOptions': askAiOptions,
'addFinishedDownloads': addFinishedDownloads, 'addFinishedDownloads': addFinishedDownloads,
'torrentStallSeconds': torrentStallSeconds,
// 비밀 값은 안전 저장소에 (54). 파일에는 저장해 두었는지만 // 비밀 값은 안전 저장소에 (54). 파일에는 저장해 두었는지만
'openSubtitlesSaved': { 'openSubtitlesSaved': {
'key': openSubtitlesKey.isNotEmpty, 'key': openSubtitlesKey.isNotEmpty,
@ -913,6 +922,7 @@ class AppSettings {
'sftpServers': [for (final x in sftpServers) x.toJson()], 'sftpServers': [for (final x in sftpServers) x.toJson()],
'smbServers': [for (final x in smbServers) x.toJson()], 'smbServers': [for (final x in smbServers) x.toJson()],
'smbRequireSecure': smbRequireSecure, 'smbRequireSecure': smbRequireSecure,
'remoteIdleSeconds': remoteIdleSeconds,
'ftpServers': [for (final x in ftpServers) x.toJson()], 'ftpServers': [for (final x in ftpServers) x.toJson()],
'rsyncOptions': rsyncOptions, 'rsyncOptions': rsyncOptions,
'robocopyOptions': robocopyOptions, 'robocopyOptions': robocopyOptions,
@ -981,6 +991,7 @@ class AppSettings {
..showHotkey = j['showHotkey'] as String? ?? 'Ctrl+Shift+X' ..showHotkey = j['showHotkey'] as String? ?? 'Ctrl+Shift+X'
..askAiOptions = j['askAiOptions'] as bool? ?? true ..askAiOptions = j['askAiOptions'] as bool? ?? true
..addFinishedDownloads = j['addFinishedDownloads'] as bool? ?? true ..addFinishedDownloads = j['addFinishedDownloads'] as bool? ?? true
..torrentStallSeconds = const [0, 30, 60, 120, 300].contains(j['torrentStallSeconds']) ? j['torrentStallSeconds'] as int : 60
..openSubtitlesKey = j['openSubtitlesKey'] as String? ?? '' ..openSubtitlesKey = j['openSubtitlesKey'] as String? ?? ''
..openSubtitlesUser = j['openSubtitlesUser'] as String? ?? '' ..openSubtitlesUser = j['openSubtitlesUser'] as String? ?? ''
..openSubtitlesPassword = j['openSubtitlesPassword'] as String? ?? '' ..openSubtitlesPassword = j['openSubtitlesPassword'] as String? ?? ''
@ -1173,6 +1184,7 @@ class AppSettings {
if (x is Map) SftpServer.fromJson(x), if (x is Map) SftpServer.fromJson(x),
] ]
..smbRequireSecure = j['smbRequireSecure'] is bool ? j['smbRequireSecure'] as bool : false ..smbRequireSecure = j['smbRequireSecure'] is bool ? j['smbRequireSecure'] as bool : false
..remoteIdleSeconds = RemoteIdle.choices.contains(j['remoteIdleSeconds']) ? j['remoteIdleSeconds'] as int : RemoteIdle.defaultSeconds
..smbServers = [ ..smbServers = [
for (final x in (j['smbServers'] as List?) ?? const []) for (final x in (j['smbServers'] as List?) ?? const [])
if (x is Map) SmbServer.fromJson(x), if (x is Map) SmbServer.fromJson(x),

View File

@ -11,6 +11,7 @@ import '../core/sync_tools.dart';
import '../core/vfs.dart'; import '../core/vfs.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import '../core/rsync_filter.dart'; import '../core/rsync_filter.dart';
import '../core/rsync_launch.dart';
/// 복사 · 이동 한 번 (파일 탐색기). 방법 (현재 방식 · rsync · robocopy) 과 상관없이 같은 진행 상태를 알린다: /// 복사 · 이동 한 번 (파일 탐색기). 방법 (현재 방식 · rsync · robocopy) 과 상관없이 같은 진행 상태를 알린다:
/// - 위쪽: 고른 항목 (폴더) 중 몇 번째인지 · 전체 진행률 /// - 위쪽: 고른 항목 (폴더) 중 몇 번째인지 · 전체 진행률
@ -268,6 +269,11 @@ class TransferJob extends ChangeNotifier {
Future<void> run() async { Future<void> run() async {
try { try {
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션이 남으면 실행하지 않는다 (rsync 가 원본 경로로 읽어 옵션이 몰래 빠짐)
if (method == CopyMethod.rsync) {
final bad = optionProblems(options);
if (bad.isNotEmpty) throw StateError(optionProblemMessage(bad));
}
for (var i = 0; i < sources.length; i++) { for (var i = 0; i < sources.length; i++) {
final s = sources[i]; final s = sources[i];
if ((isDav(s) || FileSystemEntity.isDirectorySync(s)) && isSameOrInside(dest, s)) { if ((isDav(s) || FileSystemEntity.isDirectorySync(s)) && isSameOrInside(dest, s)) {
@ -396,8 +402,20 @@ class TransferJob extends ChangeNotifier {
if (log.length > 20) log.removeAt(0); if (log.length > 20) log.removeAt(0);
} }
Future<int> _start(String exe, List<String> args, Encoding enc, void Function(String chunk) onOut) async { /// [rsync]: Windows 의 rsync 는 응답 파일 · MSYS=noglob 으로 띄운다 (P0 10/20 - ' · * 를 런타임이 다시 읽지 않게, [prepareRsyncLaunch]).
_proc = await Process.start(exe, args, runInShell: false); /// robocopy 는 Windows 프로그램이라 그대로
Future<int> _start(String exe, List<String> args, Encoding enc, void Function(String chunk) onOut, {bool rsync = false}) async {
final launch = rsync ? await prepareRsyncLaunch(args) : null;
try {
return await _startRaw(exe, launch?.args ?? args, enc, onOut, workingDirectory: launch?.workingDirectory, environment: launch?.environment);
} finally {
launch?.dispose();
}
}
Future<int> _startRaw(String exe, List<String> args, Encoding enc, void Function(String chunk) onOut,
{String? workingDirectory, Map<String, String>? environment}) async {
_proc = await Process.start(exe, args, runInShell: false, workingDirectory: workingDirectory, environment: environment);
final a = _proc!.stdout.transform(enc.decoder).listen(onOut); final a = _proc!.stdout.transform(enc.decoder).listen(onOut);
final b = _proc!.stderr.transform(enc.decoder).listen((t) { final b = _proc!.stderr.transform(enc.decoder).listen((t) {
for (final l in t.split(RegExp(r'[\r\n]'))) { for (final l in t.split(RegExp(r'[\r\n]'))) {
@ -454,6 +472,7 @@ class TransferJob extends ChangeNotifier {
} }
_tick(); _tick();
}, },
rsync: true,
); );
if (cancelled) return; if (cancelled) return;
if (!rsyncOk(code)) throw ProcessException(exe, const [], rsyncFailureText(code, errLog, log), code); if (!rsyncOk(code)) throw ProcessException(exe, const [], rsyncFailureText(code, errLog, log), code);

View File

@ -38,6 +38,26 @@ final _youtube = RegExp(
final _magnet = RegExp(r'magnet:\?xt=urn:bt[im]h:[^\s<>"]+', caseSensitive: false); final _magnet = RegExp(r'magnet:\?xt=urn:bt[im]h:[^\s<>"]+', caseSensitive: false);
final _torrentUrl = RegExp(r'https?://[^\s<>"]+\.torrent(?:\?[^\s<>"]*)?', caseSensitive: false); final _torrentUrl = RegExp(r'https?://[^\s<>"]+\.torrent(?:\?[^\s<>"]*)?', caseSensitive: false);
/// 마그넷 주소의 이름 (dn=…, 주소 글 풀기 · + 는 빈칸). 마그넷이 아니거나 이름이 없으면 null.
/// 관리자 지시 (10/20, 28 확인): 메타데이터를 받기 전에도 줄 제목에 이 이름을 보인다 (마그넷 주소 전체 대신)
String? magnetName(String url) {
if (!url.toLowerCase().startsWith('magnet:?')) return null;
for (final part in url.substring(8).split('&')) {
final i = part.indexOf('=');
if (i <= 0 || part.substring(0, i).toLowerCase() != 'dn') continue;
String name;
try {
name = Uri.decodeQueryComponent(part.substring(i + 1));
} catch (_) {
name = part.substring(i + 1); // 잘못 적힌 % 는 그대로
}
// 줄바꿈 · 제어 문자는 빈칸으로 (한 줄 제목)
name = name.replaceAll(RegExp(r'[\x00-\x1f\x7f]+'), ' ').trim();
if (name.isNotEmpty) return name;
}
return null;
}
/// 클립보드 글에서 다운로드할 주소 찾기 (중복 제거, 나온 순서대로) /// 클립보드 글에서 다운로드할 주소 찾기 (중복 제거, 나온 순서대로)
List<DetectedLink> detectLinks(String text) { List<DetectedLink> detectLinks(String text) {
final found = <int, DetectedLink>{}; final found = <int, DetectedLink>{};

View File

@ -935,7 +935,7 @@ class FileOps {
} }
for (final x in items) { for (final x in items) {
final r = '$rel/${vBasename(x.path)}'; final r = '$rel/${vBasename(x.path)}';
if (filter.excluded(r, isDir: x.isDir)) return true; if (filter.protects(r, isDir: x.isDir)) return true;
if (x.isDir && await hasExcluded(x.path, r)) return true; if (x.isDir && await hasExcluded(x.path, r)) return true;
} }
return false; return false;
@ -947,7 +947,7 @@ class FileOps {
final name = vBasename(x.path); final name = vBasename(x.path);
if (name.endsWith('.jjsync') || isAppTempName(name)) continue; if (name.endsWith('.jjsync') || isAppTempName(name)) continue;
final r = '$rel/$name'; final r = '$rel/$name';
if (filter.excluded(r, isDir: x.isDir)) continue; if (filter.protects(r, isDir: x.isDir)) continue;
if (x.isDir && await hasExcluded(x.path, r)) { if (x.isDir && await hasExcluded(x.path, r)) {
await deleteInside(x.path, r); await deleteInside(x.path, r);
} else { } else {
@ -1011,11 +1011,9 @@ class FileOps {
if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue; if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue;
_check(); _check();
final r = rel.isEmpty ? name : '$rel/$name'; final r = rel.isEmpty ? name : '$rel/$name';
if (filter.excluded(r, isDir: t.value.isDir)) { // 받는 쪽 규칙에 걸린 것만 지킨다 (관리자 지시 10/20: 보내는 쪽에서만 거른 H · -,s · --delete-excluded 의 그냥 규칙은 지움)
if (filter.deleteExcluded) await vDelete(t.value.path); if (filter.protects(r, isDir: t.value.isDir)) continue;
continue; if (t.value.isDir && filter.protectsAny && await hasExcluded(t.value.path, r)) {
}
if (t.value.isDir && !filter.isEmpty && !filter.deleteExcluded && await hasExcluded(t.value.path, r)) {
await deleteInside(t.value.path, r); await deleteInside(t.value.path, r);
continue; continue;
} }

View File

@ -10,6 +10,7 @@ import 'dns_resolver.dart';
import 'remote_fs.dart'; import 'remote_fs.dart';
import 'secret_gate.dart'; import 'secret_gate.dart';
import 'webdav.dart' show DavException, DavItem, DavLockedException; import 'webdav.dart' show DavException, DavItem, DavLockedException;
import 'remote_idle.dart';
/// FTP · FTPS 서버 설정 (관리자 지시 10/17, l). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음). /// FTP · FTPS 서버 설정 (관리자 지시 10/17, l). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
/// [security]: 'explicit' (FTPS - AUTH TLS, 처음 값) · 'implicit' (FTPS 990) · 'none' (평문 FTP - 연결 · 설정에서 "암호화 안 됨" 경고). /// [security]: 'explicit' (FTPS - AUTH TLS, 처음 값) · 'implicit' (FTPS 990) · 'none' (평문 FTP - 연결 · 설정에서 "암호화 안 됨" 경고).
@ -380,7 +381,7 @@ class FtpRemote implements RemoteClient {
} }
Object _explain(Object e) { Object _explain(Object e) {
if (e is FtpException || e is DavLockedException) return e; if (e is FtpException || e is DavLockedException || e is RemoteIdleTimeout) return e;
if (e is TimeoutException) return FtpException(trf('서버 ({0}) 가 답하지 않습니다', [server.host]), FtpFailure.unreachable); if (e is TimeoutException) return FtpException(trf('서버 ({0}) 가 답하지 않습니다', [server.host]), FtpFailure.unreachable);
if (e is SocketException) return FtpException(trf('서버 ({0}) 에 연결할 수 없습니다: {1}', [server.host, e.message]), FtpFailure.unreachable); if (e is SocketException) return FtpException(trf('서버 ({0}) 에 연결할 수 없습니다: {1}', [server.host, e.message]), FtpFailure.unreachable);
if (e is HandshakeException) return FtpException(trf('암호화 (TLS) 연결을 맺지 못했습니다: {0}', [e.message]), FtpFailure.other); if (e is HandshakeException) return FtpException(trf('암호화 (TLS) 연결을 맺지 못했습니다: {0}', [e.message]), FtpFailure.other);
@ -578,7 +579,8 @@ class FtpRemote implements RemoteClient {
Stream<List<int>> read() async* { Stream<List<int>> read() async* {
var left = length; var left = length;
try { try {
await for (var chunk in data) { // 관리자 판단 (10/20): 조각 사이에 정한 시간 동안 아무것도 오지 않으면 실패 (서버가 멈춰도 영원히 기다리지 않게)
await for (var chunk in idleRead(data, onTimeout: data.destroy)) {
if (left != null) { if (left != null) {
if (chunk.length >= left) { if (chunk.length >= left) {
yield chunk.sublist(0, left); yield chunk.sublist(0, left);
@ -614,8 +616,11 @@ class FtpRemote implements RemoteClient {
throw s.failure(code, text, rel); throw s.failure(code, text, rel);
} }
final sock = await s.secureData(plain); final sock = await s.secureData(plain);
await sock.addStream(data); // 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하면 실패 (연결을 끊음)
await sock.flush(); await idleWrite(data, (x) async {
await sock.addStream(x);
await sock.flush();
}, onTimeout: sock.destroy);
// 닫기 (TLS 는 close_notify) 를 보내고, 서버가 답하지 않아도 오래 기다리지 않게 연결째 끊는다 // 닫기 (TLS 는 close_notify) 를 보내고, 서버가 답하지 않아도 오래 기다리지 않게 연결째 끊는다
// 다 보낸 뒤 닫는다 (TLS 는 close_notify). close() 가 끝나도 암호화된 마지막 자료가 아직 나가지 않았을 수 있어 // 다 보낸 뒤 닫는다 (TLS 는 close_notify). close() 가 끝나도 암호화된 마지막 자료가 아직 나가지 않았을 수 있어
// 연결이 정말 끝날 때 (done) 까지 기다리고, 서버가 답하지 않을 때만 끊는다 (곧바로 destroy 하면 빈 파일이 되었다) // 연결이 정말 끝날 때 (done) 까지 기다리고, 서버가 답하지 않을 때만 끊는다 (곧바로 destroy 하면 빈 파일이 되었다)

119
lib/core/remote_idle.dart Normal file
View File

@ -0,0 +1,119 @@
import 'dart:async';
import '../l10n/tr.dart';
/// 관리자 판단 (10/20): 원격 서버 (FTP · SFTP · SMB · WebDAV) 의 받기 · 올리기 자료 연결에서 조각 사이에 정한 시간 동안
/// 아무것도 오가지 않으면 실패한다. 예전에는 서버가 중간에 멈추면 복사 · 동기화가 영원히 멈춰 있었다 (명령의 답에만 시간 제한).
/// 정한 시간은 설정 "원격 서버 응답 기다리기" (처음 60초 · 30 · 60 · 120 · 300초) - 앱이 [limit] 에 넣는다
class RemoteIdle {
static const choices = [30, 60, 120, 300];
static const defaultSeconds = 60;
static Duration limit = const Duration(seconds: defaultSeconds);
}
/// 조각 사이에 정한 시간이 지나도 서버가 아무것도 주고받지 않음
class RemoteIdleTimeout implements Exception {
final int seconds;
const RemoteIdleTimeout(this.seconds);
@override
String toString() => trf('서버가 답하지 않습니다 ({0}초)', [seconds]);
}
/// 받기: 조각 사이가 [limit] (없으면 [RemoteIdle.limit]) 를 넘으면 [RemoteIdleTimeout] 으로 끝나는 스트림.
/// 그때 [onTimeout] 으로 그 연결을 끊고 원래 스트림의 구독도 끊는다 (늦게 오는 조각 · 오류를 받지 않음).
/// 받는 쪽이 멈춰 (pause) 있는 동안은 세지 않는다
Stream<T> idleRead<T>(Stream<T> s, {Duration? limit, void Function()? onTimeout}) {
final d = limit ?? RemoteIdle.limit;
late StreamController<T> out;
StreamSubscription<T>? sub;
Timer? t;
void arm() {
t?.cancel();
t = Timer(d, () {
final x = sub;
sub = null;
// 오류를 먼저 넣고 닫은 뒤에 연결을 끊는다 - 먼저 끊으면 원래 스트림의 "끝" 이 먼저 와서 정상으로 끝난 것처럼 보였다
if (!out.isClosed) {
out.addError(RemoteIdleTimeout(d.inSeconds));
unawaited(out.close());
}
onTimeout?.call();
unawaited(Future(() => x?.cancel()).catchError((_) {}));
});
}
out = StreamController<T>(
onListen: () {
arm();
sub = s.listen((v) {
if (out.isClosed) return;
arm();
out.add(v);
}, onError: (Object e, StackTrace st) {
if (out.isClosed) return;
arm();
out.addError(e, st);
}, onDone: () {
t?.cancel();
if (!out.isClosed) unawaited(out.close());
});
},
onPause: () {
t?.cancel();
sub?.pause();
},
onResume: () {
sub?.resume();
arm();
},
onCancel: () {
t?.cancel();
final x = sub;
sub = null;
return x?.cancel();
},
);
return out.stream;
}
/// 올리기: [send] 가 [data] 에서 [limit] 동안 한 조각도 더 가져가지 않으면 (서버가 받지 않아 보내기가 막힘) 또는 다 가져간 뒤
/// [limit] 안에 끝나지 않으면 (서버의 마지막 답이 없음) [RemoteIdleTimeout]. 그때 [onTimeout] 으로 그 연결을 끊는다
Future<T> idleWrite<T>(Stream<List<int>> data, Future<T> Function(Stream<List<int>> s) send,
{Duration? limit, void Function()? onTimeout}) {
final d = limit ?? RemoteIdle.limit;
final fail = Completer<T>();
Timer? t;
void arm() {
t?.cancel();
t = Timer(d, () {
if (fail.isCompleted) return;
fail.completeError(RemoteIdleTimeout(d.inSeconds));
onTimeout?.call();
});
}
arm();
final work = send(data.map((x) {
arm();
return x;
}));
work.then((v) {
t?.cancel();
if (!fail.isCompleted) fail.complete(v);
}, onError: (Object e, StackTrace st) {
t?.cancel();
if (!fail.isCompleted) fail.completeError(e, st);
});
return fail.future;
}
/// 한 번의 요청 (SMB 조각 읽기 · 쓰기, WebDAV 응답 기다리기) 에 같은 시간 제한
Future<T> idleCall<T>(Future<T> f, {Duration? limit, void Function()? onTimeout}) {
final d = limit ?? RemoteIdle.limit;
// 시간이 지나 연결을 끊은 뒤 원래 요청이 늦게 실패해도 (연결 닫힘 등) 처리되지 않은 오류로 남지 않게
f.ignore();
return f.timeout(d, onTimeout: () {
onTimeout?.call();
throw RemoteIdleTimeout(d.inSeconds);
});
}

View File

@ -15,6 +15,10 @@ import 'sync_tools.dart' show splitOptions;
/// - `이름/***` 은 그 폴더와 안의 모든 것. /// - `이름/***` 은 그 폴더와 안의 모든 것.
/// - 거른 폴더 안으로는 내려가지 않는다. 받는 쪽의 거른 것은 지우지 않는다 (--delete-excluded 면 지운다). /// - 거른 폴더 안으로는 내려가지 않는다. 받는 쪽의 거른 것은 지우지 않는다 (--delete-excluded 면 지운다).
/// - rsync 처럼 바이트 단위: `?` · `[…]` 는 UTF-8 한 바이트 (그래서 `?.txt` 는 "가.txt" 에 맞지 않는다). /// - rsync 처럼 바이트 단위: `?` · `[…]` 는 UTF-8 한 바이트 (그래서 `?.txt` 는 "가.txt" 에 맞지 않는다).
/// - 관리자 지시 (10/20): 규칙마다 걸리는 쪽 - 그냥 `+` · `-` 는 양쪽, `H` (hide) = `-,s` · `S` (show) = `+,s` 는 보내는 쪽
/// (보낼 것 고르기) 에만, `P` (protect) = `-,r` · `R` (risk) = `+,r` 은 받는 쪽 (지울 것 고르기) 에만. 쪽마다 그 쪽에 걸리는
/// 규칙만 위에서부터 보고 처음 맞는 것이 이긴다. --delete-excluded 면 쪽을 적지 않은 규칙은 보내는 쪽에만 (rsync 와 같음).
/// 보내는 쪽에서 거른 것 (보내지 않음) 은 받는 쪽에서 지울 것이 되고, 받는 쪽 규칙에 걸린 것만 지킨다.
class RsyncFilter { class RsyncFilter {
final List<RsyncFilterRule> rules; final List<RsyncFilterRule> rules;
@ -30,6 +34,13 @@ class RsyncFilter {
bool get isEmpty => rules.isEmpty && !deleteExcluded; bool get isEmpty => rules.isEmpty && !deleteExcluded;
/// 받는 쪽에서 지키는 규칙이 하나라도 있는지 (없으면 받는 쪽에만 있는 것은 모두 지운다 - 폴더를 뒤질 필요 없음)
bool get protectsAny => rules.any((r) => !r.include && _onReceiver(r));
bool _onSender(RsyncFilterRule r) => r.side != RuleSide.receiver;
bool _onReceiver(RsyncFilterRule r) =>
r.side == RuleSide.receiver || r.side == RuleSide.bothExplicit || (r.side == RuleSide.both && !deleteExcluded);
/// [options] (rsync 옵션 칸의 글) 에서 규칙을 읽는다. [readLines] 는 --exclude-from 파일 읽기 (시험에서 바꿈) /// [options] (rsync 옵션 칸의 글) 에서 규칙을 읽는다. [readLines] 는 --exclude-from 파일 읽기 (시험에서 바꿈)
factory RsyncFilter.parse(String options, {List<String>? Function(String path)? readLines}) { factory RsyncFilter.parse(String options, {List<String>? Function(String path)? readLines}) {
final read = readLines ?? _readLines; final read = readLines ?? _readLines;
@ -63,15 +74,34 @@ class RsyncFilter {
} }
void filterRule(String r) { void filterRule(String r) {
// 15 검토 F1: 고치개 (",s" 보내는 쪽만 · ",r" 받는 쪽만 · ",!" 반대 · ",p" 등) 가 붙은 규칙은 뜻이 달라 // 관리자 지시 (10/20): H · S · P · R 과 고치개 ",s" (보내는 쪽만) · ",r" (받는 쪽만) 를 rsync 와 같게 따른다
// (예: "-,s a.txt" 는 받는 쪽 a.txt 를 지키지 않아 --delete 로 지워진다) 미리 볼 수 없다 - 적용하지 않고 알린다. // (예: "-,s a.txt" · "H a.txt" 는 보내지 않고, 받는 쪽 a.txt 는 지키지 않아 --delete 로 지워진다 - 미리 보기에도 그렇게).
// H · S · P · R · merge 도 같다 // 그 밖의 고치개 (",!" 반대 · ",p" · ",x" · ",e" 등) · merge · "!" 는 미리 볼 수 없다 - 적용하지 않고 알린다
final m = RegExp(r'^(\+|-|include|exclude)\s+(.+)$').firstMatch(r.trim()); final m = RegExp(r'^(\+|-|include|exclude|H|S|P|R|hide|show|protect|risk)(?:,([a-zA-Z!/]*))?\s+(.+)$').firstMatch(r.trim());
if (m == null) { if (m == null) {
unsupported.add('--filter=$r'); unsupported.add('--filter=$r');
return; return;
} }
rules.add(RsyncFilterRule(m.group(2)!, include: m.group(1) == '+' || m.group(1) == 'include')); final name = m.group(1)!, mods = m.group(2) ?? '';
final short = const {'include': '+', 'exclude': '-', 'hide': 'H', 'show': 'S', 'protect': 'P', 'risk': 'R'}[name] ?? name;
// 쪽 (s · r) 말고 다른 고치개는 미리 볼 수 없다. H · S · P · R 에는 쪽 고치개도 받지 않는다 (이미 쪽이 정해짐)
if (mods.contains(RegExp(r'[^sr]')) || ('HSPR'.contains(short) && mods.isNotEmpty)) {
unsupported.add('--filter=$r');
return;
}
final include = short == '+' || short == 'S' || short == 'R';
final side = switch (short) {
'H' || 'S' => RuleSide.sender,
'P' || 'R' => RuleSide.receiver,
_ => mods.contains('s') && !mods.contains('r')
? RuleSide.sender
: mods.contains('r') && !mods.contains('s')
? RuleSide.receiver
: mods.isEmpty
? RuleSide.both
: RuleSide.bothExplicit,
};
rules.add(RsyncFilterRule(m.group(3)!, include: include, side: side));
} }
for (var i = 0; i < t.length; i++) { for (var i = 0; i < t.length; i++) {
@ -93,19 +123,29 @@ class RsyncFilter {
fromFile(v!, true); fromFile(v!, true);
} else if ((v = value('--filter')) != null) { } else if ((v = value('--filter')) != null) {
filterRule(v!); filterRule(v!);
} else if (o == '-f' && i + 1 < t.length) {
filterRule(t[++i]);
} else if (o.startsWith('-f') && !o.startsWith('--')) {
// 28 확인: -f"+ *.txt" 처럼 붙여 쓴 것 (따옴표가 벗겨져 "-f+ *.txt" 한 낱말) - 예전에는 말없이 버렸다
filterRule(o.substring(2));
} else if (o == '--files-from' || o.startsWith('--files-from=')) { } else if (o == '--files-from' || o.startsWith('--files-from=')) {
// 28 확인: 고른 파일만 보내므로 미리 보기와 다르다 (미리 보기는 더 많이 보임) - 알린다 // 28 확인: 고른 파일만 보내므로 미리 보기와 다르다 (미리 보기는 더 많이 보임) - 알린다
if (o == '--files-from' && i + 1 < t.length) i++; if (o == '--files-from' && i + 1 < t.length) i++;
unsupported.add(o); unsupported.add(o);
} else if (o == '--delete-excluded') { } else if (o == '--delete-excluded') {
deleteExcluded = true; deleteExcluded = true;
} else if (o == '--cvs-exclude' || o == '-F' || (o.startsWith('-') && !o.startsWith('--') && (o.contains('C') || o.contains('F')))) { } else if (o == '--cvs-exclude') {
unsupported.add(o); unsupported.add(o);
} else if (o.length > 1 && o.startsWith('-') && !o.startsWith('--')) {
// 짧은 옵션 묶음 (-avPog · -avf"- *.log" · -rf "- x"): rsync (popt) 처럼 한 글자씩 푼다 (28 확인).
// 값을 받는 글자 (B · e · f · M · T · @) 를 만나면 그 뒤 글이 값, 비어 있으면 다음 낱말이 값.
// 예전에는 -f 가 맨 앞일 때만 읽고, 묶음 안의 C · F 를 패턴 글자까지 뒤져 잘못 알렸다
for (var j = 1; j < o.length; j++) {
final ch = o[j];
if ('BefMT@'.contains(ch)) {
var arg = o.substring(j + 1);
if (arg.isEmpty && i + 1 < t.length) arg = t[++i];
if (ch == 'f') filterRule(arg);
break;
}
// -C (CVS 빼기 목록) · -F (.rsync-filter 읽기) 는 미리 볼 수 없다
if (ch == 'C' || ch == 'F') unsupported.add('-$ch');
}
} }
} }
return RsyncFilter(rules, deleteExcluded: deleteExcluded, unsupported: unsupported); return RsyncFilter(rules, deleteExcluded: deleteExcluded, unsupported: unsupported);
@ -122,22 +162,35 @@ class RsyncFilter {
} }
} }
/// 옮기는 맨 위 기준 상대 경로 [rel] ("/" 구분) 이 걸러지는지 /// 옮기는 맨 위 기준 상대 경로 [rel] ("/" 구분) 이 보내는 쪽에서 걸러지는지 (보내지 않음 · 그 폴더 안으로 내려가지 않음)
bool excluded(String rel, {required bool isDir}) { bool excluded(String rel, {required bool isDir}) {
for (final r in rules) { for (final r in rules) {
if (r.matches(rel, isDir: isDir)) return !r.include; if (_onSender(r) && r.matches(rel, isDir: isDir)) return !r.include;
}
return false;
}
/// 받는 쪽의 [rel] 을 지우기 (--delete) 에서 지키는지 - 받는 쪽에 걸리는 규칙 가운데 처음 맞는 것이 빼기 (- · P) 면 지킨다.
/// 보내는 쪽에만 거른 것 (H · -,s · --delete-excluded 의 그냥 규칙) 은 지키지 않는다
bool protects(String rel, {required bool isDir}) {
for (final r in rules) {
if (_onReceiver(r) && r.matches(rel, isDir: isDir)) return !r.include;
} }
return false; return false;
} }
} }
/// 규칙이 걸리는 쪽. [both] = 쪽을 적지 않은 규칙 (--delete-excluded 면 보내는 쪽만), [bothExplicit] = ",sr" 로 양쪽을 적음
enum RuleSide { both, bothExplicit, sender, receiver }
class RsyncFilterRule { class RsyncFilterRule {
final String pattern; final String pattern;
final bool include; final bool include;
final RuleSide side;
late final bool _dirOnly; late final bool _dirOnly;
late final RegExp _re; late final RegExp _re;
RsyncFilterRule(this.pattern, {required this.include}) { RsyncFilterRule(this.pattern, {required this.include, this.side = RuleSide.both}) {
var p = pattern; var p = pattern;
var all = false; // "이름/***" = 그 폴더와 안의 모든 것 var all = false; // "이름/***" = 그 폴더와 안의 모든 것
if (p.endsWith('/***')) { if (p.endsWith('/***')) {
@ -150,7 +203,9 @@ class RsyncFilterRule {
} }
final anchored = p.startsWith('/'); final anchored = p.startsWith('/');
if (anchored) p = p.substring(1); if (anchored) p = p.substring(1);
final whole = anchored || p.contains('/') || p.contains('**'); // "이름/***" 은 원래 패턴에 / 가 있으므로 경로로 맞춘다 (28 확인 10/20: 이름으로만 맞추면 받는 쪽에만 있는 keep/sub 같은
// 안쪽 폴더를 "P keep/***" 가 지키지 못해 미리 보기가 더 지운다고 보였다 - 거른 폴더 안으로 내려가지 않는 보내는 쪽에서는 드러나지 않음)
final whole = anchored || all || p.contains('/') || p.contains('**');
// rsync 의 wildmatch 는 바이트로 맞춘다 - 패턴 · 이름을 UTF-8 바이트 (한 글자 = 한 바이트) 로 바꿔 맞춘다 // rsync 의 wildmatch 는 바이트로 맞춘다 - 패턴 · 이름을 UTF-8 바이트 (한 글자 = 한 바이트) 로 바꿔 맞춘다
final body = _glob(_bytes(p)) + (all ? '(?:/.*)?' : ''); final body = _glob(_bytes(p)) + (all ? '(?:/.*)?' : '');
_re = RegExp(anchored ? '^$body\$' : whole ? '^(?:.*/)?$body\$' : '^$body\$'); _re = RegExp(anchored ? '^$body\$' : whole ? '^(?:.*/)?$body\$' : '^$body\$');

View File

@ -0,0 +1,44 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import '../l10n/tr.dart';
/// 관리자 지시 (10/20, 28 재현 - P0 데이터): Windows 의 rsync (MSYS2 · Cygwin 런타임) 는 Windows 프로그램이 넘긴 명령 줄을
/// 스스로 다시 읽는다 - ' 를 따옴표로 짝지어 (--exclude=it's.txt --exclude=Bob's.txt 가 한 인수로 합쳐져 거르기가 사라지고
/// 지킬 파일이 지워짐, ' 가 든 경로는 합쳐져 실패) * ? [ ] { } 를 작업 폴더의 파일로 펼친다 (--exclude *.log 가 x.log y.log 로).
/// 앱이 만든 인수를 응답 파일 (@파일) 에 큰따옴표로 감싸 넣고 MSYS=noglob 으로 띄우면 그대로 간다 (실제 rsync 3.5.1 로 확인 -
/// MSYS=noglob 만으로는 ' 를 막지 못하고, CYGWIN=noglob 은 소용없다).
/// 응답 파일의 경로에 빈칸 · ' 가 있으면 @ 가 먹히지 않아, 그 파일이 든 폴더를 작업 폴더로 하고 "@args.txt" 로 넘긴다.
/// 큰따옴표 (") 가 든 인수는 이 방법으로도 그대로 넘기지 못한다 - 실행하지 않고 이유를 알린다 (Windows 파일 이름에는 " 가 없다).
class RsyncLaunch {
final List<String> args;
final String? workingDirectory;
final Map<String, String>? environment;
final Directory? _dir;
RsyncLaunch._(this.args, this.workingDirectory, this.environment, this._dir);
/// 다 쓴 응답 파일을 지운다 (rsync 가 끝난 뒤)
void dispose() {
try {
_dir?.deleteSync(recursive: true);
} catch (_) {}
}
}
/// Windows rsync 에 그대로 넘길 수 없는 인수 (큰따옴표가 듦)
List<String> msysUnsafeArgs(List<String> args) => [for (final a in args) if (a.contains('"')) a];
/// [args] 를 rsync 에 그대로 넘기도록 준비한다 (Windows 만 응답 파일 · MSYS=noglob, 그 밖은 그대로). 끝나면 [RsyncLaunch.dispose]
Future<RsyncLaunch> prepareRsyncLaunch(List<String> args, {bool? windows}) async {
if (!(windows ?? Platform.isWindows)) return RsyncLaunch._(args, null, null, null);
final bad = msysUnsafeArgs(args);
if (bad.isNotEmpty) {
throw StateError(trf('큰따옴표 (") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}', [bad.join(' · ')]));
}
final dir = await Directory.systemTemp.createTemp('jj_rsargs_');
await File(p.join(dir.path, 'args.txt')).writeAsString(args.map((a) => '"$a"').join(' '), encoding: utf8);
final msys = Platform.environment['MSYS'];
return RsyncLaunch._(['@args.txt'], dir.path, {'MSYS': msys == null || msys.trim().isEmpty ? 'noglob' : '$msys noglob'}, dir);
}

View File

@ -10,6 +10,7 @@ import 'dns_resolver.dart';
import 'remote_fs.dart'; import 'remote_fs.dart';
import 'secret_gate.dart'; import 'secret_gate.dart';
import 'webdav.dart' show DavException, DavItem, DavLockedException; import 'webdav.dart' show DavException, DavItem, DavLockedException;
import 'remote_idle.dart';
/// 88: SFTP 서버 설정 (환경 설정 > 파일 탐색기 > 네트워크 서버). 비밀번호 · 개인 키 · 키 암호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음). /// 88: SFTP 서버 설정 (환경 설정 > 파일 탐색기 > 네트워크 서버). 비밀번호 · 개인 키 · 키 암호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
/// 호스트 키 (지문) 는 비밀이 아니라 설정에 둔다 - 처음 연결 때 사용자가 믿은 것 (TOFU). /// 호스트 키 (지문) 는 비밀이 아니라 설정에 둔다 - 처음 연결 때 사용자가 믿은 것 (TOFU).
@ -266,8 +267,13 @@ class SftpRemote implements RemoteClient {
return x; return x;
} }
/// 관리자 판단 (10/20): SFTP 명령 하나 (목록 · 열기 · 닫기 · 지우기 등) 를 정한 시간 ([RemoteIdle]) 까지만 기다린다 -
/// 서버가 멈추면 이 연결을 닫고 (다음에 다시 연다) "서버가 답하지 않습니다"
Future<T> _cmd<T>(Future<T> f) => idleCall(f, onTimeout: close);
/// 파일 다루다 난 오류 (SFTP 상태) → 사람 말. 끊긴 연결은 다음에 다시 연다 /// 파일 다루다 난 오류 (SFTP 상태) → 사람 말. 끊긴 연결은 다음에 다시 연다
Never _fail(Object e, String rel) { Never _fail(Object e, String rel) {
if (e is RemoteIdleTimeout) throw e;
if (e is SftpStatusError) { if (e is SftpStatusError) {
switch (e.code) { switch (e.code) {
case SftpStatusCode.noSuchFile: case SftpStatusCode.noSuchFile:
@ -298,13 +304,13 @@ class SftpRemote implements RemoteClient {
final c = await _open(); final c = await _open();
try { try {
final out = <DavItem>[]; final out = <DavItem>[];
for (final n in await c.sftp.listdir(_remote(c, rel))) { for (final n in await _cmd(c.sftp.listdir(_remote(c, rel)))) {
if (n.filename == '.' || n.filename == '..') continue; if (n.filename == '.' || n.filename == '..') continue;
var a = n.attr; var a = n.attr;
// 링크는 가리키는 것으로 (폴더 링크를 폴더로) - 따라갈 수 없으면 크기 0 파일로 // 링크는 가리키는 것으로 (폴더 링크를 폴더로) - 따라갈 수 없으면 크기 0 파일로
if (a.type == SftpFileType.symbolicLink) { if (a.type == SftpFileType.symbolicLink) {
try { try {
a = await c.sftp.stat(_remote(c, _child(rel, n.filename))); a = await _cmd(c.sftp.stat(_remote(c, _child(rel, n.filename))));
} catch (_) {} } catch (_) {}
} }
out.add(_item(_child(rel, n.filename), a)); out.add(_item(_child(rel, n.filename), a));
@ -319,7 +325,7 @@ class SftpRemote implements RemoteClient {
Future<DavItem?> stat(String rel) async { Future<DavItem?> stat(String rel) async {
final c = await _open(); final c = await _open();
try { try {
return _item(rel, await c.sftp.stat(_remote(c, rel))); return _item(rel, await _cmd(c.sftp.stat(_remote(c, rel))));
} on SftpStatusError catch (e) { } on SftpStatusError catch (e) {
if (e.code == SftpStatusCode.noSuchFile) return null; if (e.code == SftpStatusCode.noSuchFile) return null;
_fail(e, rel); _fail(e, rel);
@ -339,7 +345,7 @@ class SftpRemote implements RemoteClient {
await mkdirs(_parent(rel)); await mkdirs(_parent(rel));
final c = await _open(); final c = await _open();
try { try {
await c.sftp.mkdir(_remote(c, rel)); await _cmd(c.sftp.mkdir(_remote(c, rel)));
} catch (e) { } catch (e) {
// 그 사이에 다른 곳이 만들었으면 그대로 // 그 사이에 다른 곳이 만들었으면 그대로
if ((await stat(rel))?.isDir == true) return; if ((await stat(rel))?.isDir == true) return;
@ -357,9 +363,9 @@ class SftpRemote implements RemoteClient {
for (final x in await list(rel)) { for (final x in await list(rel)) {
await delete(x.rel); await delete(x.rel);
} }
await c.sftp.rmdir(_remote(c, rel)); await _cmd(c.sftp.rmdir(_remote(c, rel)));
} else { } else {
await c.sftp.remove(_remote(c, rel)); await _cmd(c.sftp.remove(_remote(c, rel)));
} }
} catch (e) { } catch (e) {
_fail(e, rel); _fail(e, rel);
@ -374,14 +380,14 @@ class SftpRemote implements RemoteClient {
final t = await stat(to); final t = await stat(to);
if (t != null && !t.isDir) { if (t != null && !t.isDir) {
try { try {
await c.sftp.remove(_remote(c, to)); await _cmd(c.sftp.remove(_remote(c, to)));
} catch (e) { } catch (e) {
_fail(e, to); _fail(e, to);
} }
} }
} }
try { try {
await c.sftp.rename(_remote(c, from), _remote(c, to)); await _cmd(c.sftp.rename(_remote(c, from), _remote(c, to)));
} catch (e) { } catch (e) {
_fail(e, from); _fail(e, from);
} }
@ -395,7 +401,7 @@ class SftpRemote implements RemoteClient {
final c = await _open(); final c = await _open();
final SftpFile f; final SftpFile f;
try { try {
f = await c.sftp.open(_remote(c, rel)); f = await _cmd(c.sftp.open(_remote(c, rel)));
} catch (e) { } catch (e) {
_fail(e, rel); _fail(e, rel);
} }
@ -403,14 +409,15 @@ class SftpRemote implements RemoteClient {
StreamSubscription<Uint8List>? sub; StreamSubscription<Uint8List>? sub;
out = StreamController<List<int>>( out = StreamController<List<int>>(
onListen: () { onListen: () {
sub = f.read(offset: offset, length: length).listen(out.add, onError: (Object e) { // 관리자 판단 (10/20): 조각 사이에 정한 시간 동안 아무것도 오지 않으면 실패 - 그 연결은 닫고 다음에 다시 연다
sub = idleRead(f.read(offset: offset, length: length), onTimeout: close).listen(out.add, onError: (Object e) {
try { try {
_fail(e, rel); _fail(e, rel);
} catch (x) { } catch (x) {
out.addError(x); out.addError(x);
} }
}, onDone: () async { }, onDone: () async {
await f.close().catchError((_) {}); await _cmd(f.close()).catchError((_) {});
await out.close(); await out.close();
}); });
}, },
@ -418,7 +425,7 @@ class SftpRemote implements RemoteClient {
onResume: () => sub?.resume(), onResume: () => sub?.resume(),
onCancel: () async { onCancel: () async {
await sub?.cancel(); await sub?.cancel();
await f.close().catchError((_) {}); await _cmd(f.close()).catchError((_) {});
}, },
); );
return out.stream; return out.stream;
@ -429,17 +436,18 @@ class SftpRemote implements RemoteClient {
final c = await _open(); final c = await _open();
final SftpFile f; final SftpFile f;
try { try {
f = await c.sftp.open(_remote(c, rel), f = await _cmd(c.sftp.open(_remote(c, rel),
mode: SftpFileOpenMode.create | SftpFileOpenMode.truncate | SftpFileOpenMode.write); mode: SftpFileOpenMode.create | SftpFileOpenMode.truncate | SftpFileOpenMode.write));
} catch (e) { } catch (e) {
_fail(e, rel); _fail(e, rel);
} }
try { try {
await f.write(data.map((x) => x is Uint8List ? x : Uint8List.fromList(x))).done; // 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하면 실패 (그 연결을 닫음)
await idleWrite(data, (x) => f.write(x.map((y) => y is Uint8List ? y : Uint8List.fromList(y))).done, onTimeout: close);
} catch (e) { } catch (e) {
_fail(e, rel); _fail(e, rel);
} finally { } finally {
await f.close().catchError((_) {}); await _cmd(f.close()).catchError((_) {});
} }
} }
@ -447,7 +455,7 @@ class SftpRemote implements RemoteClient {
Future<(int?, int?)> quota() async { Future<(int?, int?)> quota() async {
final c = await _open(); final c = await _open();
try { try {
final v = await c.sftp.statvfs(c.root); final v = await _cmd(c.sftp.statvfs(c.root));
final unit = v.fundamentalBlockSize > 0 ? v.fundamentalBlockSize : v.blockSize; final unit = v.fundamentalBlockSize > 0 ? v.fundamentalBlockSize : v.blockSize;
return (v.freeBlocksForNonRoot * unit, (v.totalBlocks - v.freeBlocks) * unit); return (v.freeBlocksForNonRoot * unit, (v.totalBlocks - v.freeBlocks) * unit);
} catch (_) { } catch (_) {

View File

@ -10,6 +10,7 @@ import '../l10n/tr.dart';
import 'remote_fs.dart'; import 'remote_fs.dart';
import 'secret_gate.dart'; import 'secret_gate.dart';
import 'webdav.dart' show DavException, DavItem, DavLockedException; import 'webdav.dart' show DavException, DavItem, DavLockedException;
import 'remote_idle.dart';
/// 88: SMB 서버 설정 (Android 만 - Windows 는 운영체제가 \\서버\공유 를 연다, 사용자 결정 88-1). /// 88: SMB 서버 설정 (Android 만 - Windows 는 운영체제가 \\서버\공유 를 연다, 사용자 결정 88-1).
/// 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음). /// 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
@ -245,8 +246,9 @@ class SmbRemote implements RemoteClient {
while (left == null || left > 0) { while (left == null || left > 0) {
final Uint8List? data; final Uint8List? data;
try { try {
data = await channel.invokeMethod<Uint8List>( // 관리자 판단 (10/20): 한 조각을 정한 시간 안에 받지 못하면 실패 (NAS 가 멈춰도 영원히 기다리지 않게)
'readChunk', {'handle': handle, 'offset': offset, 'length': left == null ? chunk : min(chunk, left)}); data = await idleCall(channel.invokeMethod<Uint8List>(
'readChunk', {'handle': handle, 'offset': offset, 'length': left == null ? chunk : min(chunk, left)}));
} on PlatformException catch (e) { } on PlatformException catch (e) {
throw _explain(e, rel); throw _explain(e, rel);
} }
@ -276,8 +278,8 @@ class SmbRemote implements RemoteClient {
while (bytes.length - at >= chunk || (all && at < bytes.length)) { while (bytes.length - at >= chunk || (all && at < bytes.length)) {
final end = (at + chunk).clamp(0, bytes.length); final end = (at + chunk).clamp(0, bytes.length);
try { try {
await channel.invokeMethod<void>( await idleCall(channel.invokeMethod<void>(
'writeChunk', {'handle': handle, 'offset': offset, 'data': Uint8List.sublistView(bytes, at, end)}); 'writeChunk', {'handle': handle, 'offset': offset, 'data': Uint8List.sublistView(bytes, at, end)}));
} on PlatformException catch (e) { } on PlatformException catch (e) {
throw _explain(e, rel); throw _explain(e, rel);
} }

View File

@ -135,7 +135,7 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
final name = vBasename(x.path); final name = vBasename(x.path);
if (isAppTempName(name)) continue; if (isAppTempName(name)) continue;
final r = '$rel/$name'; final r = '$rel/$name';
if (filter.excluded(r, isDir: x.isDir)) continue; if (filter.protects(r, isDir: x.isDir)) continue;
if (x.isDir && await _hasExcluded(x.path, r, filter)) { if (x.isDir && await _hasExcluded(x.path, r, filter)) {
await deleteInside(x.path, r); await deleteInside(x.path, r);
} else { } else {
@ -211,16 +211,12 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
// 15 검토 F4: 받는 쪽에만 있는 받다 만 파일 (*.part 등) 도 --delete 면 지워진다 - 지울 목록에 (지우지 않으면 셀 것 없음) // 15 검토 F4: 받는 쪽에만 있는 받다 만 파일 (*.part 등) 도 --delete 면 지워진다 - 지울 목록에 (지우지 않으면 셀 것 없음)
if (names.contains(name) || isAppTempName(name) || (isPartialFile(name) && !delete)) continue; if (names.contains(name) || isAppTempName(name) || (isPartialFile(name) && !delete)) continue;
final r = rel.isEmpty ? name : '$rel/$name'; final r = rel.isEmpty ? name : '$rel/$name';
if (filter.excluded(r, isDir: e.isDir)) { // 받는 쪽 규칙 (- · P · R · ,r) 에 걸린 것은 rsync 가 지우지 않는다. 관리자 지시 (10/20): 보내는 쪽에서만 거른 것
// 거른 것은 rsync 가 건드리지 않는다 (--delete-excluded 와 --delete 면 지운다) // (H · -,s · --delete-excluded 의 그냥 규칙) 은 보내지 않았으므로 --delete 면 지운다. 지우지 않을 때는 거른 것을 세지 않음 (예전과 같음)
if (delete && filter.deleteExcluded) { if (filter.protects(r, isDir: e.isDir) || (!delete && filter.excluded(r, isDir: e.isDir))) continue;
out.add(PreviewItem(r, PreviewAction.delete, toRight: toRight, isDir: e.isDir, size: e.size)); // 177 (28 확인): 받는 쪽에만 있는 폴더 안에 지키는 것이 있으면 rsync 는 그것을 지키느라 폴더를 남기고
}
continue;
}
// 177 (28 확인): 받는 쪽에만 있는 폴더 안에 거른 것이 있으면 rsync 는 그것을 지키느라 폴더를 남기고
// 거르지 않은 것만 지운다 - 폴더 통째로 "지워짐" 이 아니라 안의 것만 // 거르지 않은 것만 지운다 - 폴더 통째로 "지워짐" 이 아니라 안의 것만
if (delete && e.isDir && !filter.isEmpty && !filter.deleteExcluded && await _hasExcluded(e.path, r, filter)) { if (delete && e.isDir && filter.protectsAny && await _hasExcluded(e.path, r, filter)) {
await deleteInside(e.path, r); await deleteInside(e.path, r);
continue; continue;
} }
@ -233,7 +229,7 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
return out; return out;
} }
/// 177: 받는 쪽 폴더 [dir] (상대 경로 [rel]) 안 어딘가에 걸러지는 것이 있는지 (거른 폴더 안으로는 들어가지 않는다) /// 177: 받는 쪽 폴더 [dir] (상대 경로 [rel]) 안 어딘가에 받는 쪽에서 지키는 것이 있는지 (지키는 폴더 안으로는 들어가지 않는다)
Future<bool> _hasExcluded(String dir, String rel, RsyncFilter filter) async { Future<bool> _hasExcluded(String dir, String rel, RsyncFilter filter) async {
List<({String path, bool isDir, int size, DateTime modified})> items; List<({String path, bool isDir, int size, DateTime modified})> items;
try { try {
@ -245,7 +241,7 @@ Future<bool> _hasExcluded(String dir, String rel, RsyncFilter filter) async {
final name = vBasename(x.path); final name = vBasename(x.path);
if (isPartialFile(name) || isAppTempName(name)) continue; if (isPartialFile(name) || isAppTempName(name)) continue;
final r = '$rel/$name'; final r = '$rel/$name';
if (filter.excluded(r, isDir: x.isDir)) return true; if (filter.protects(r, isDir: x.isDir)) return true;
if (x.isDir && await _hasExcluded(x.path, r, filter)) return true; if (x.isDir && await _hasExcluded(x.path, r, filter)) return true;
} }
return false; return false;

View File

@ -1,6 +1,9 @@
import 'dart:io' show Platform;
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'file_ops.dart' show appTempRsyncExcludes; import 'file_ops.dart' show appTempRsyncExcludes;
import '../l10n/tr.dart';
/// 파일 탐색기의 복사 · 이동 방법 (환경 설정 > 파일 탐색기 > 복사 · 동기화) /// 파일 탐색기의 복사 · 이동 방법 (환경 설정 > 파일 탐색기 > 복사 · 동기화)
/// ///
@ -21,36 +24,400 @@ enum CopyMethod {
const defaultRsyncOptions = '-avPog'; const defaultRsyncOptions = '-avPog';
const defaultRobocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2'; const defaultRobocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2';
/// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로) /// 관리자 지시 (10/20): 화면에서 줄바꿈 없이 묶은 옵션 글 (sync_preview_view 의 unbreakableOption) 의 줄바꿈 없는 빈칸 (U+00A0 등) ·
List<String> splitOptions(String s) { /// 보이지 않는 글자 (U+2060 등) 가 복사 · 붙여 넣기로 옵션에 들어오면 rsync 가 다른 인수로 읽는다 ("-", 보이지 않는 글자, "-filter") → 보통 빈칸으로 / 지움.
final out = <String>[]; /// 다른 곳 (웹 페이지 · 문서) 에서 붙여 넣은 것도 같다
/// 28 확인 (10/20): 소프트 하이픈 (U+00AD) 은 지우고 (rsync 가 "unknown option" 으로 실패), 전각 빈칸 (U+3000) 도 보통 빈칸으로
String cleanOptionText(String s) => s
.replaceAll(RegExp('[\u00A0\u2007\u202F\u3000]'), ' ')
.replaceAll(RegExp('[\u00AD\u200B\u200C\u200D\u2060\uFEFF]'), '');
/// 낱말 맨 앞에서 - 로 읽는 대시 (‐ ‑ ‒ – — ― − ﹘ ﹣ -) - 워드 · 웹이 -- 를 바꿔 놓은 것
const _dashLike = '\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uFE58\uFE63\uFF0D';
/// 굽은 따옴표 → 곧은 따옴표 (같은 무리끼리 열고 닫는다)
const _curlyQuote = {'\u2018': "'", '\u2019': "'", '\u201A': "'", '\u201C': '"', '\u201D': '"', '\u201E': '"'};
/// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로). 먼저 [cleanOptionText].
/// 관리자 지시 (10/20, 28 · 15 확인): 웹 · 문서에서 붙여 넣은 글을 곧은 글자로 쓴 것과 같게 읽는다 - 예전에는
/// --exclude=‘*.log’ 가 아무것도 거르지 못해 받는 쪽 keep.log 가 지워졌고, –delete 는 원본 경로로 넘어가 --delete 가 몰래 빠졌다.
/// - 굽은 따옴표 (‘ ’ “ ”) 는 낱말 맨 앞 · = 뒤 · 짧은 옵션 (-f) 뒤에서 열고, 같은 무리의 굽은 따옴표에서 닫는다
/// (곧은 따옴표처럼 뒤에 글이 이어져도 - “a b”.txt → a b.txt). 작은따옴표 무리는 글자 사이 (it’s) 이면 닫지 않는다.
/// 이름 안의 굽은 따옴표 (it’s.txt) 는 그대로 둔다.
/// - 닫히지 않은 따옴표 (15 확인 F1): 예전에는 뒤의 옵션을 모두 삼켜 지키려던 --exclude 가 사라졌다 → 그 따옴표를 글자로 보고
/// 나머지는 그대로 나누며, [unreadableOptions] 가 알려 실행을 막는다.
/// - 낱말 맨 앞의 대시 (– — ‑ - 등, 따옴표 밖): 둘이면 --, 하나면 rsync 의 긴 옵션 이름 (–delete) 일 때만 -- 그 밖 (–avz) 은 -.
/// 옵션의 값 자리 (--exclude –x.txt 의 –x.txt) · 낱말 가운데의 대시는 그대로 (파일 이름). --filter · -f 값의 규칙 머리 (“– *.tmp”) 는 - 로.
List<String> splitOptions(String s) => _parseOptions(s).words;
/// rsync 3.x 의 긴 옵션 이름 (앱에 든 rsync 3.5.1 의 --help) - 대시 하나로 붙여 넣은 –delete 를 --delete 로 읽을지 정한다
const _rsyncLong = {
'acls', 'address', 'append', 'append-verify', 'archive', 'atimes', 'backup', 'backup-dir', 'block-size', 'blocking-io',
'bwlimit', 'cc', 'checksum', 'checksum-choice', 'checksum-seed', 'chmod', 'chown', 'compare-dest', 'compress',
'compress-choice', 'compress-level', 'compress-threads', 'confine-root', 'contimeout', 'copy-as', 'copy-dest',
'copy-devices', 'copy-dirlinks', 'copy-links', 'copy-unsafe-links', 'crtimes', 'cvs-exclude', 'daemon', 'debug', 'del',
'delay-updates', 'delete', 'delete-after', 'delete-before', 'delete-delay', 'delete-during', 'delete-excluded',
'delete-missing-args', 'devices', 'dirs', 'dry-run', 'early-input', 'exclude', 'exclude-from', 'executability', 'existing',
'fake-super', 'files-from', 'filter', 'force', 'from0', 'fsync', 'fuzzy', 'group', 'groupmap', 'hard-links', 'help',
'human-readable', 'i-r', 'iconv', 'ignore-errors', 'ignore-existing', 'ignore-missing-args', 'ignore-non-existing',
'ignore-times', 'inc-recursive', 'include', 'include-from', 'info', 'inplace', 'insecure-links', 'ipv4', 'ipv6',
'itemize-changes', 'keep-dirlinks', 'link-dest', 'links', 'list-only', 'log-file', 'log-file-format', 'max-alloc',
'max-delete', 'max-size', 'min-size', 'mkpath', 'modify-window', 'munge-links', 'no-i-r', 'no-implied-dirs',
'no-inc-recursive', 'no-motd', 'no-whole-file', 'numeric-ids', 'old-args', 'old-d', 'old-dirs', 'omit-dir-times',
'omit-link-times', 'one-file-system', 'only-write-batch', 'open-noatime', 'out-format', 'outbuf', 'owner', 'partial',
'partial-dir', 'password-file', 'perms', 'port', 'preallocate', 'progress', 'protocol', 'prune-empty-dirs', 'quiet',
'read-batch', 'recursive', 'relative', 'remote-option', 'remove-source-files', 'rsh', 'rsync-path', 'safe-links',
'secluded-args', 'size-only', 'skip-compress', 'sockopts', 'sparse', 'specials', 'stats', 'stderr', 'stop-after', 'stop-at',
'suffix', 'super', 'temp-dir', 'timeout', 'times', 'trust-sender', 'update', 'usermap', 'verbose', 'version',
'whole-file', 'write-batch', 'write-devices', 'xattrs', 'zc', 'zl', 'zt',
};
/// 나눈 낱말: 맨 앞이 따옴표 안이었는지 (그러면 맨 앞 대시를 옵션으로 고치지 않는다)
typedef _Word = ({String text, bool quoted});
/// [splitOptions] · [unreadableOptions] 의 몸통: 낱말들과 닫히지 않은 따옴표 (뒤 글 일부)
({List<String> words, List<String> unclosed}) _parseOptions(String s) {
final chars = cleanOptionText(s).split('');
final literal = <int>{}; // 닫히지 않아 글자로 보는 따옴표 자리
final unclosed = <String>[];
while (true) {
final (raw, open) = _splitRaw(chars, literal);
if (open == null) return (words: _normalizeWords(raw), unclosed: unclosed);
literal.add(open);
final rest = chars.sublist(open).join().trim();
unclosed.add(rest.length > 30 ? '${rest.substring(0, 30)}…' : rest);
}
}
/// 따옴표 · 빈칸으로 나눈다. 끝까지 닫히지 않은 따옴표가 있으면 그 자리 (두 번째 값)
(List<_Word>, int?) _splitRaw(List<String> chars, Set<int> literal) {
final out = <_Word>[];
final cur = StringBuffer(); final cur = StringBuffer();
String? quote; String? quote; // 닫는 곧은 따옴표 (" 또는 ')
var curly = false; // 굽은 따옴표로 열었는지
var openAt = -1;
var has = false; var has = false;
for (final ch in s.split('')) { var quoted = false;
bool letter(int i) => i >= 0 && i < chars.length && RegExp(r'[\p{L}\p{N}]', unicode: true).hasMatch(chars[i]);
for (var i = 0; i < chars.length; i++) {
final ch = chars[i];
if (quote != null) { if (quote != null) {
if (ch == quote) { // 작은따옴표 무리의 ’ 가 글자 사이면 (it’s) 닫지 않는다
final closes = ch == quote && !curly ||
curly && _curlyQuote[ch] == quote && !(quote == "'" && letter(i - 1) && letter(i + 1));
if (closes) {
quote = null; quote = null;
curly = false;
} else { } else {
cur.write(ch); cur.write(ch);
} }
} else if (literal.contains(i)) {
cur.write(ch);
} else if (ch == '"' || ch == "'") { } else if (ch == '"' || ch == "'") {
if (cur.isEmpty && !has) quoted = true;
quote = ch; quote = ch;
openAt = i;
has = true;
} else if (_curlyQuote.containsKey(ch) && (cur.isEmpty || '$cur'.endsWith('=') || RegExp(r'^-[A-Za-z0-9]+$').hasMatch('$cur'))) {
if (cur.isEmpty && !has) quoted = true;
quote = _curlyQuote[ch];
curly = true;
openAt = i;
has = true; has = true;
} else if (ch.trim().isEmpty) { } else if (ch.trim().isEmpty) {
if (cur.isNotEmpty || has) out.add(cur.toString()); if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted));
cur.clear(); cur.clear();
has = false; has = false;
quoted = false;
} else { } else {
cur.write(ch); cur.write(ch);
} }
} }
if (cur.isNotEmpty || has) out.add(cur.toString()); if (quote != null) return (out, openAt);
if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted));
return (out, null);
}
bool _dashAt(String t, int i) => i < t.length && (t[i] == '-' || _dashLike.contains(t[i]));
/// 거르기 규칙 머리 ("– *.tmp" · "+ *.txt") 를 rsync 가 읽는 - · + 로
String _fixRuleHead(String v) {
if (v.isEmpty) return v;
if (_dashLike.contains(v[0])) return '-${v.substring(1)}';
if (v[0] == '\uFF0B') return '+${v.substring(1)}';
return v;
}
/// 낱말 맨 앞 대시 고치기 · 거르기 값의 규칙 머리 고치기 (값 자리는 그대로)
List<String> _normalizeWords(List<_Word> raw) {
final out = <String>[];
var value = ''; // 다음 낱말이 값: 'filter' (규칙 머리를 고침) · 'other' (그대로)
for (final w in raw) {
var t = w.text;
if (value.isNotEmpty) {
out.add(value == 'filter' ? _fixRuleHead(t) : t);
value = '';
continue;
}
// 28 확인 (10/20): 곧은 하이픈과 섞인 것 (-–delete) 도 맨 앞 둘 중 하나라도 대시면 같은 규칙으로
if (!w.quoted && t.isNotEmpty && (_dashLike.contains(t[0]) || t.length > 1 && t[0] == '-' && _dashLike.contains(t[1]))) {
final n = _dashAt(t, 1) ? 2 : 1;
final rest = t.substring(n);
final name = rest.split('=').first;
final long = n == 2 || _rsyncLong.contains(name) || (name.startsWith('no-') && _rsyncLong.contains(name.substring(3)));
t = '${long ? '--' : '-'}$rest';
}
if (t.startsWith('--filter=')) {
t = '--filter=${_fixRuleHead(t.substring(9))}';
} else if (t.length > 1 && t.startsWith('-') && !t.startsWith('--')) {
final k = t.indexOf(RegExp('[BefMT@]'), 1);
if (k > 0 && t[k] == 'f' && k < t.length - 1) t = '${t.substring(0, k + 1)}${_fixRuleHead(t.substring(k + 1))}';
}
out.add(t);
if (t.startsWith('--')) {
if (!t.contains('=') && _rsyncLongWithValue.contains(t)) value = t == '--filter' ? 'filter' : 'other';
} else if (t.length > 1 && t.startsWith('-')) {
final k = t.indexOf(RegExp('[BefMT@]'), 1);
if (k == t.length - 1) value = t[k] == 'f' ? 'filter' : 'other';
}
}
return out;
}
/// 값을 다음 낱말로 받는 rsync 긴 옵션 (--exclude *.log) - [unreadableOptions] 가 그 값을 옵션으로 보지 않게
const _rsyncLongWithValue = {
'--exclude', '--include', '--filter', '--exclude-from', '--include-from', '--files-from', '--backup-dir', '--suffix',
'--log-file', '--log-file-format', '--out-format', '--chmod', '--chown', '--usermap', '--groupmap', '--bwlimit',
'--timeout', '--contimeout', '--rsh', '--rsync-path', '--compare-dest', '--copy-dest', '--link-dest', '--partial-dir',
'--temp-dir', '--max-size', '--min-size', '--max-delete', '--modify-window', '--password-file', '--iconv',
'--block-size', '--checksum-choice', '--compress-choice', '--compress-level', '--skip-compress', '--info', '--debug',
'--sockopts', '--port', '--address', '--outbuf', '--remote-option', '--max-alloc', '--stop-after', '--stop-at',
'--write-batch', '--only-write-batch', '--read-batch', '--protocol', '--checksum-seed', '--copy-as', '--early-input',
// 28 확인 (10/20): rsync --help 의 짧은 별칭 (aka --cc · --zc · --zl · --zt) 과 값 받는 옵션 - 빠져 있어 값이 "경로로 읽힙니다" 로 막혔다
'--cc', '--zc', '--zl', '--zt', '--compress-threads', '--confine-root',
};
/// 관리자 지시 (10/20, 28 · 15 확인): [splitOptions] 로 고쳐 읽은 뒤에도 rsync 가 다르게 읽을 것 - 있으면 확인 창 · 실시간 동기화에
/// 빨갛게 알리고 실행을 막는다: 닫히지 않은 따옴표, 옵션 자리의 - 로 시작하지 않는 낱말 (rsync 는 원본 경로로 읽어 옵션이 몰래 빠짐),
/// 하이픈 셋 이상, 끝에 값 없이 남은 값 받는 옵션. 옵션의 값 자리 (--exclude 한글.txt 의 한글.txt, -f 뒤) 는 세지 않는다
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, stdin }
/// [unreadableOptions] 의 한 줄과 그 종류
class OptionProblem {
final OptionProblemKind kind;
final String text;
/// [text] 안의 옵션 낱말 (화면이 줄바꿈 없이 묶는다)
final String token;
const OptionProblem(this.kind, this.text, [this.token = '']);
@override
String toString() => text;
}
/// 문제 종류에 맞는 고칠 방법 (여러 종류면 · 로 잇는다)
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('따옴표를 닫아 주세요'),
if (kinds.contains(OptionProblemKind.path)) tr('옵션은 - 나 -- 로 시작합니다'),
if (kinds.contains(OptionProblemKind.noValue)) tr('값을 적어 주세요 (예: --exclude=*.tmp)'),
if (kinds.contains(OptionProblemKind.odd)) tr('곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'),
if (kinds.contains(OptionProblemKind.doubleQuote)) tr('큰따옴표 (") 를 빼 주세요'),
// 관리자 지시 (10/20): Windows 는 드라이브로 시작하는 전체 경로만 ("/" 는 rsync 가 자기 설치 폴더 기준으로 읽음), Android 는 /
if (kinds.contains(OptionProblemKind.relativePath)) windows ? tr('C:\\ 처럼 드라이브로 시작하는 전체 경로로') : tr('/ 로 시작하는 전체 경로로'),
if (kinds.contains(OptionProblemKind.stdin)) tr('목록 파일의 전체 경로를 적어 주세요'),
].join(' · ');
}
/// "옵션을 알아볼 수 없습니다: … - 고칠 방법" (작업 기록 · 실시간 동기화 카드 · 설정 칸)
String optionProblemMessage(List<OptionProblem> problems) =>
trf('옵션을 알아볼 수 없습니다: {0} - {1}', [problems.join(' · '), optionProblemHint(problems)]);
/// [unreadableOptions] 의 몸통 (종류와 함께)
List<OptionProblem> optionProblems(String options, {bool? windows}) {
windows ??= Platform.isWindows;
final parsed = _parseOptions(options);
// 15 확인 F1: 닫히지 않은 따옴표 - 뒤의 옵션이 다르게 읽힐 수 있다
final bad = [for (final u in parsed.unclosed) OptionProblem(OptionProblemKind.unclosed, trf('따옴표가 닫히지 않았습니다: {0}', [u]), u)];
var value = false;
var last = '';
for (final t in parsed.words) {
// P0 (10/20): Windows 의 rsync (MSYS2) 에는 큰따옴표가 든 인수를 그대로 넘길 수 없다 (prepareRsyncLaunch) - 값 자리도.
// 닫히지 않은 따옴표로 이미 막혔으면 (그 따옴표가 낱말에 남음) 한 번만 알린다
if (windows && parsed.unclosed.isEmpty && t.contains('"')) {
bad.add(OptionProblem(OptionProblemKind.doubleQuote, trf('큰따옴표 (") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}', [t]), t));
value = false;
continue;
}
if (value) {
value = false;
continue;
}
if (t.startsWith('---')) {
// 관리자 판단 (10/20, 28 확인): 하이픈이 셋 이상 (---delete) 은 rsync 가 알아보지 못한다 - 끝 문구는 "옵션은 - 나 -- 로 시작합니다"
bad.add(OptionProblem(OptionProblemKind.path, t, t));
} else if (t.startsWith('--')) {
value = !t.contains('=') && _rsyncLongWithValue.contains(t);
last = t;
} else if (t.length > 1 && t.startsWith('-')) {
// 짧은 옵션 묶음: 값을 받는 글자 (B · e · f · M · T · @) 가 끝이면 다음 낱말이 값 (rsync_filter 와 같은 규칙)
final k = t.indexOf(RegExp('[BefMT@]'), 1);
value = k == t.length - 1;
last = t;
} else {
// 관리자 판단 (10/20, 28 확인): 옵션 칸에는 경로가 들어갈 자리가 없다 (원본 · 대상은 앱이 넣음) - 값 자리가 아닌데
// - 로 시작하지 않는 낱말 (-a delete 의 delete, 혼자 쓴 -) 은 rsync 가 원본 경로로 읽어 옵션이 몰래 빠진다
// ASCII 밖 글자로 시작하면 (« … 등) 붙여 넣은 이상한 글자 - 안내는 곧은 글자로 다시 적기
bad.add(OptionProblem(t.codeUnitAt(0) > 0x7F ? OptionProblemKind.odd : OptionProblemKind.path, trf('경로로 읽힙니다: {0}', [t]), t));
}
}
// 값이 필요한 옵션이 끝에 값 없이 (--exclude) - rsync 가 원본 경로를 그 값으로 먹는다
if (value) bad.add(OptionProblem(OptionProblemKind.noValue, trf('{0} 의 값이 없습니다', [last]), last));
// 관리자 판단 (10/20): 목록 파일의 상대 경로 - rsync 는 자기 작업 폴더 (Windows 는 응답 파일 폴더) 기준으로, 미리 보기는 앱의
// 작업 폴더 기준으로 읽어 서로 다른 파일을 볼 수 있다. 전체 경로만 받는다
for (final f in listFileValues(parsed.words)) {
// 관리자 지시 (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, windows: windows)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('기록 · 배치 파일은 전체 경로로 적어 주세요: {0}', [f]), f));
}
return bad;
}
/// 긴 옵션 [names] 의 값 (--이름=값 · --이름 값)
List<String> _optionValues(List<String> words, Set<String> names) => [
for (var i = 0; i < words.length; i++)
if (words[i].contains('=') && names.contains(words[i].substring(0, words[i].indexOf('='))))
words[i].substring(words[i].indexOf('=') + 1)
else if (names.contains(words[i]) && i + 1 < words.length)
words[i + 1],
];
/// 파일을 받는 옵션의 값 (--exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일").
/// ": 이름" · dir-merge 는 폴더마다 읽는 파일 이름이라 (원래 상대) 빼고
List<String> listFileValues(List<String> words) {
const fileOpts = {'--exclude-from', '--include-from', '--files-from'};
final out = <String>[];
String? mergeFile(String rule) => RegExp(r'^(?:\.|merge)(?:,[^\s]*)?\s+(.+)$').firstMatch(rule.trim())?.group(1);
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 && fileOpts.contains(w.substring(0, eq))) {
out.add(w.substring(eq + 1));
} else if (fileOpts.contains(w) && next != null) {
out.add(next);
i++;
} else if (w.startsWith('--filter=')) {
final f = mergeFile(w.substring(9));
if (f != null) out.add(f);
} else if (w == '--filter' && next != null) {
final f = mergeFile(next);
if (f != null) out.add(f);
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') continue;
final rule = k == w.length - 1 ? next : w.substring(k + 1);
if (k == w.length - 1) i++;
final f = rule == null ? null : mergeFile(rule);
if (f != null) out.add(f);
}
}
return out;
}
/// 전체 경로. 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; return out;
} }
/// Windows 경로 → MSYS2 rsync 가 읽는 경로 (C:\a\b → /cygdrive/c/a/b, \\server\share\x → //server/share/x). /// Windows 경로 → MSYS2 rsync 가 읽는 경로 (C:\a\b → /cygdrive/c/a/b, \\server\share\x → //server/share/x).
/// MSYS2 rsync 는 "C:" 를 원격 컴퓨터 이름으로 읽으므로 꼭 바꿔야 한다. /// 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}) { String toCygwinPath(String path, {bool windows = true}) {
if (!windows) return path; if (!windows) return path;
final s = path.replaceAll('\\', '/'); final s = path.replaceAll('\\', '/');
@ -78,7 +445,7 @@ List<String> rsyncArgs({
bool contents = false, bool contents = false,
}) { }) {
// 178: 원본을 지우는 옵션은 [move] (이동 · [원본 파일 지우기]) 로만 - 옵션 글에 있어도 빼고, 이동일 때만 넣는다 // 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 [ return [
...opts, ...opts,
if (bandwidthKBps > 0 && !opts.any((o) => o.startsWith('--bwlimit'))) '--bwlimit=$bandwidthKBps', if (bandwidthKBps > 0 && !opts.any((o) => o.startsWith('--bwlimit'))) '--bwlimit=$bandwidthKBps',
@ -88,7 +455,7 @@ List<String> rsyncArgs({
...appTempRsyncExcludes, // 78 보충: 일괄 이름 바꾸기 중의 임시 이름 (~jjren_ · ~jjback_) 은 보내지 않는다 ...appTempRsyncExcludes, // 78 보충: 일괄 이름 바꾸기 중의 임시 이름 (~jjren_ · ~jjback_) 은 보내지 않는다
// [contents]: 폴더 "안의 것" 을 대상에 (rsync 의 끝 / - 동기화 · lsync 와 같은 모양) // [contents]: 폴더 "안의 것" 을 대상에 (rsync 의 끝 / - 동기화 · lsync 와 같은 모양)
for (final s in sources) '${toCygwinPath(_noTrailingSlash(s), windows: windows)}${contents ? '/' : ''}', for (final s in sources) '${toCygwinPath(_noTrailingSlash(s), windows: windows)}${contents ? '/' : ''}',
'${toCygwinPath(_noTrailingSlash(dest), windows: windows)}/', '${rsyncDestPath(_noTrailingSlash(dest), windows: windows)}/',
]; ];
} }

View File

@ -9,6 +9,7 @@ import 'sftp.dart';
import 'smb.dart'; import 'smb.dart';
import 'webdav.dart'; import 'webdav.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'remote_idle.dart';
/// 로컬 경로와 원격 경로 (WebDAV `dav://<서버 id>/<경로>` · 88 SFTP `sftp://<서버 id>/<경로>`) 를 같이 다루는 도구. /// 로컬 경로와 원격 경로 (WebDAV `dav://<서버 id>/<경로>` · 88 SFTP `sftp://<서버 id>/<경로>`) 를 같이 다루는 도구.
/// 로컬 경로는 원래 코드 (dart:io) 를 그대로 쓰고, 원격만 여기서 서버 종류에 맞는 클라이언트 ([RemoteClient]) 로 처리한다. /// 로컬 경로는 원래 코드 (dart:io) 를 그대로 쓰고, 원격만 여기서 서버 종류에 맞는 클라이언트 ([RemoteClient]) 로 처리한다.
@ -235,8 +236,9 @@ Future<void> vWrite(String path, Stream<List<int>> data, {int? length, DateTime?
try { try {
await d.client.write(tmp.rel, data, length: length); await d.client.write(tmp.rel, data, length: length);
} catch (_) { } catch (_) {
// 받다 만 .jjpart 를 지운다 - 서버가 멈췄으면 지우기도 답이 없을 수 있어 정한 시간까지만 (못 지우면 다음 정리 (71) 에서)
try { try {
await d.client.delete(tmp.rel); await idleCall(d.client.delete(tmp.rel));
} catch (_) {} } catch (_) {}
rethrow; rethrow;
} }
@ -248,15 +250,18 @@ Future<void> vWrite(String path, Stream<List<int>> data, {int? length, DateTime?
try { try {
await out.addStream(data); await out.addStream(data);
} catch (_) { } catch (_) {
// 실패 · 취소: 반쯤 쓴 임시 파일을 남기지 않는다 (원래 파일은 그대로) // 실패 · 취소: 반쯤 쓴 임시 파일을 남기지 않는다 (원래 파일은 그대로).
await out.close(); // 10/20 (15, 멈추는 서버 시험으로 찾음): 예전에는 finally 에서 한 번 더 닫다가 난 "File closed" 가 진짜 이유
// (서버가 답하지 않습니다 · 연결 끊김 등) 를 덮어썼다 - 닫기 · 지우기의 오류는 삼키고 원래 오류를 그대로
try {
await out.close();
} catch (_) {}
try { try {
await File(tmp).delete(); await File(tmp).delete();
} catch (_) {} } catch (_) {}
rethrow; rethrow;
} finally {
await out.close();
} }
await out.close();
if (await File(path).exists()) await File(path).delete(); if (await File(path).exists()) await File(path).delete();
await File(tmp).rename(path); await File(tmp).rename(path);
if (modified != null) { if (modified != null) {

View File

@ -7,6 +7,7 @@ import 'package:xml/xml.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'remote_fs.dart'; import 'remote_fs.dart';
import 'secret_gate.dart'; import 'secret_gate.dart';
import 'remote_idle.dart';
/// WebDAV 서버 설정 (환경 설정 > 파일 탐색기 > WebDAV). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음). /// WebDAV 서버 설정 (환경 설정 > 파일 탐색기 > WebDAV). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
class DavServer { class DavServer {
@ -115,6 +116,10 @@ class DavClient implements RemoteClient {
? null ? null
: 'Basic ${base64Encode(utf8.encode('${server.user}:${server.password}'))}'; : 'Basic ${base64Encode(utf8.encode('${server.user}:${server.password}'))}';
/// 관리자 판단 (10/20): 답 (머리) 을 정한 시간 ([RemoteIdle]) 까지만 기다린다 - 서버가 멈추면 그 연결을 끊고 "서버가 답하지 않습니다".
/// 답의 몸은 [idleRead] 로 (조각 사이)
Future<HttpClientResponse> _send(HttpClientRequest req) => idleCall(req.close(), onTimeout: req.abort);
Future<HttpClientRequest> _open(String method, Uri uri) async { Future<HttpClientRequest> _open(String method, Uri uri) async {
// 124: 저장된 비밀번호로 접속하기 전에 (마스터 비밀번호를 정해 두었으면 묻는다) // 124: 저장된 비밀번호로 접속하기 전에 (마스터 비밀번호를 정해 두었으면 묻는다)
if (server.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage)); if (server.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage));
@ -164,8 +169,8 @@ class DavClient implements RemoteClient {
final bytes = utf8.encode(body); final bytes = utf8.encode(body);
req.contentLength = bytes.length; req.contentLength = bytes.length;
req.add(bytes); req.add(bytes);
final res = await req.close(); final res = await _send(req);
final text = await res.transform(utf8.decoder).join(); final text = await idleRead(res).transform(utf8.decoder).join();
if (res.statusCode == 404) throw DavException(trf('없는 경로: {0}', [rel]), status: 404); if (res.statusCode == 404) throw DavException(trf('없는 경로: {0}', [rel]), status: 404);
if (res.statusCode != 207 && res.statusCode != 200) _fail(tr('목록'), res, text); if (res.statusCode != 207 && res.statusCode != 200) _fail(tr('목록'), res, text);
return text; return text;
@ -231,8 +236,8 @@ class DavClient implements RemoteClient {
/// 폴더 만들기 (이미 있으면 그대로) /// 폴더 만들기 (이미 있으면 그대로)
Future<void> mkdir(String rel) async { Future<void> mkdir(String rel) async {
final res = await (await _open('MKCOL', uriOf(rel, dir: true))).close(); final res = await _send(await _open('MKCOL', uriOf(rel, dir: true)));
await res.drain<void>(); await idleRead(res).drain<void>();
if (res.statusCode == 201 || res.statusCode == 200) return; if (res.statusCode == 201 || res.statusCode == 200) return;
if (res.statusCode == 405 && (await stat(rel))?.isDir == true) return; // 이미 있음 if (res.statusCode == 405 && (await stat(rel))?.isDir == true) return; // 이미 있음
_fail(tr('폴더 만들기'), res); _fail(tr('폴더 만들기'), res);
@ -252,8 +257,8 @@ class DavClient implements RemoteClient {
@override @override
Future<void> delete(String rel) async { Future<void> delete(String rel) async {
final st = await stat(rel); final st = await stat(rel);
final res = await (await _open('DELETE', uriOf(rel, dir: st?.isDir ?? false))).close(); final res = await _send(await _open('DELETE', uriOf(rel, dir: st?.isDir ?? false)));
await res.drain<void>(); await idleRead(res).drain<void>();
if (res.statusCode == 404) return; if (res.statusCode == 404) return;
if (res.statusCode >= 300) _fail(tr('지우기'), res); if (res.statusCode >= 300) _fail(tr('지우기'), res);
} }
@ -265,8 +270,8 @@ class DavClient implements RemoteClient {
..set('Destination', uriOf(to, dir: st?.isDir ?? false).toString()) ..set('Destination', uriOf(to, dir: st?.isDir ?? false).toString())
..set('Overwrite', overwrite ? 'T' : 'F'); ..set('Overwrite', overwrite ? 'T' : 'F');
if (st?.isDir ?? false) req.headers.set('Depth', 'infinity'); if (st?.isDir ?? false) req.headers.set('Depth', 'infinity');
final res = await req.close(); final res = await _send(req);
await res.drain<void>(); await idleRead(res).drain<void>();
if (res.statusCode >= 300) _fail(method == 'MOVE' ? tr('이동') : tr('복사'), res); if (res.statusCode >= 300) _fail(method == 'MOVE' ? tr('이동') : tr('복사'), res);
} }
@ -277,12 +282,14 @@ class DavClient implements RemoteClient {
/// 받기: 내용을 조각으로 (끝나면 스트림이 닫힘) /// 받기: 내용을 조각으로 (끝나면 스트림이 닫힘)
@override @override
Future<Stream<List<int>>> openRead(String rel) async { Future<Stream<List<int>>> openRead(String rel) async {
final res = await (await _open('GET', uriOf(rel))).close(); // 관리자 판단 (10/20): 답 (머리) 과 조각 사이를 정한 시간까지만 기다린다 - 서버가 멈추면 연결을 끊고 실패
final req = await _open('GET', uriOf(rel));
final res = await _send(req);
if (res.statusCode != 200) { if (res.statusCode != 200) {
await res.drain<void>(); await res.drain<void>();
_fail(tr('받기'), res); _fail(tr('받기'), res);
} }
return res; return idleRead(res);
} }
/// 올리기 ([length] 를 알면 Content-Length 로) /// 올리기 ([length] 를 알면 Content-Length 로)
@ -290,8 +297,11 @@ class DavClient implements RemoteClient {
Future<void> write(String rel, Stream<List<int>> data, {int? length}) async { Future<void> write(String rel, Stream<List<int>> data, {int? length}) async {
final req = await _open('PUT', uriOf(rel)); final req = await _open('PUT', uriOf(rel));
if (length != null) req.contentLength = length; if (length != null) req.contentLength = length;
await req.addStream(data); // 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하거나 다 보낸 뒤 답이 없으면 실패 (연결을 끊음)
final res = await req.close(); final res = await idleWrite(data, (x) async {
await req.addStream(x);
return req.close();
}, onTimeout: req.abort);
await res.drain<void>(); await res.drain<void>();
if (res.statusCode >= 300) _fail(tr('올리기'), res); if (res.statusCode >= 300) _fail(tr('올리기'), res);
} }

View File

@ -83,7 +83,8 @@ class AndroidDownloadTools {
}), }),
_Deferred(DownloadKind.torrent, () async { _Deferred(DownloadKind.torrent, () async {
final t = await ready(); final t = await ready();
return Aria2Backend(aria2c: t.aria2c, environment: t.environment, extraArgs: ['--ca-certificate=${t.cert}']); return Aria2Backend(aria2c: t.aria2c, environment: t.environment, extraArgs: ['--ca-certificate=${t.cert}'],
stallAfter: () => Duration(seconds: s().torrentStallSeconds));
}), }),
]; ];
} }

View File

@ -28,8 +28,12 @@ class Aria2Backend implements DownloadBackend {
final Map<String, String> environment; final Map<String, String> environment;
final List<String> extraArgs; final List<String> extraArgs;
Aria2Backend({String? aria2c, this.environment = const {}, this.extraArgs = const []}) /// 마그넷 · 토렌트가 이 시간 동안 메타데이터 · 피어를 못 받으면 그 줄에 알린다 (환경 설정, 0 = 알리지 않음)
: aria2c = aria2c ?? locateTool('aria2c'); final Duration Function() stallAfter;
Aria2Backend({String? aria2c, this.environment = const {}, this.extraArgs = const [], Duration Function()? stallAfter})
: aria2c = aria2c ?? locateTool('aria2c'),
stallAfter = stallAfter ?? (() => const Duration(seconds: 60));
@override @override
DownloadKind get kind => DownloadKind.torrent; DownloadKind get kind => DownloadKind.torrent;
@ -69,6 +73,9 @@ class Aria2Backend implements DownloadBackend {
'--bt-save-metadata=false', '--follow-torrent=mem', '--bt-save-metadata=false', '--follow-torrent=mem',
'--console-log-level=warn', '--summary-interval=0', '--console-log-level=warn', '--summary-interval=0',
'--file-allocation=none', '--auto-file-renaming=false', '--file-allocation=none', '--auto-file-renaming=false',
// 28 확인 (10/20): 인터넷 IPv6 가 없는데 (Tailscale fd7a:… 같은 ULA 뿐) IPv6 가 있다고 보이면 aria2 가 트래커 · 피어에
// IPv6 로 먼저 붙다 실패하고 IPv4 로 다시 시도하지 않아 마그넷이 시작되지 않았다 - 그럴 때만 IPv6 를 끈다
if (!await _internetIpv6()) '--disable-ipv6=true',
...extraArgs, ...extraArgs,
...dns, ...dns,
], environment: environment.isEmpty ? null : environment); ], environment: environment.isEmpty ? null : environment);
@ -146,6 +153,32 @@ class Aria2Backend implements DownloadBackend {
changed(); changed();
} }
/// 28 확인: 이 기기의 인터넷 IPv6 · IPv4 (모르면 있다고 본다 - aria2 기본대로)
static Future<bool> _internetIpv6() async {
try {
final l = await NetworkInterface.list(type: InternetAddressType.IPv6, includeLinkLocal: false);
return hasInternetIpv6(l.expand((i) => i.addresses));
} catch (_) {
return true;
}
}
static Future<bool> _internetIpv4() async {
try {
final l = await NetworkInterface.list(type: InternetAddressType.IPv4, includeLinkLocal: false);
return hasInternetIpv4(l.expand((i) => i.addresses));
} catch (_) {
return true;
}
}
/// 멈춘 마그넷 · 토렌트의 이유 후보 (화면 글은 downloads_page 의 [stallReasonText])
static Future<List<String>> _stallReasons() async => [
if (!await _internetIpv4()) 'ipv6only',
'firewall',
if (NetGuard.instance.forcing) 'dns',
];
Future<void> _refresh() async { Future<void> _refresh() async {
for (final (t, changed) in _tasks.values.toList()) { for (final (t, changed) in _tasks.values.toList()) {
final gid = t.extra['gid'] as String?; final gid = t.extra['gid'] as String?;
@ -154,7 +187,7 @@ class Aria2Backend implements DownloadBackend {
final s = await _call('aria2.tellStatus', [ final s = await _call('aria2.tellStatus', [
gid, gid,
['status', 'totalLength', 'completedLength', 'downloadSpeed', 'followedBy', ['status', 'totalLength', 'completedLength', 'downloadSpeed', 'followedBy',
'bittorrent', 'files', 'errorMessage'], 'bittorrent', 'files', 'errorMessage', 'connections'],
]) as Map; ]) as Map;
// 마그넷: 메타데이터를 받은 뒤 실제 다운로드로 넘어감 // 마그넷: 메타데이터를 받은 뒤 실제 다운로드로 넘어감
final followed = s['followedBy'] as List?; final followed = s['followedBy'] as List?;
@ -180,6 +213,26 @@ class Aria2Backend implements DownloadBackend {
..eta = speed > 0 && total > done ? _eta((total - done) ~/ speed) : ''; ..eta = speed > 0 && total > done ? _eta((total - done) ~/ speed) : '';
// 35: aria2 가 순서를 기다리게 한 것은 "받는 중" 이 아니라 "대기 중" 으로 // 35: aria2 가 순서를 기다리게 한 것은 "받는 중" 이 아니라 "대기 중" 으로
t.extra['waiting'] = s['status'] == 'waiting'; t.extra['waiting'] = s['status'] == 'waiting';
// 관리자 지시 (10/20): 마그넷 · 토렌트가 메타데이터나 피어를 정한 시간 동안 못 받으면 그 줄에 알린다
// (예전에는 아무 표시 없이 "받는 중" 이라 왜 안 되는지 알 수 없었다). 받은 것이 생기면 지운다
final torrent = s['bittorrent'] != null || t.source.toLowerCase().startsWith('magnet:');
final noInfo = ((s['bittorrent'] as Map?)?['info']) == null;
final conns = int.tryParse('${s['connections']}') ?? 0;
final after = stallAfter();
if (torrent && s['status'] == 'active' && done == 0 && (noInfo || conns == 0) && after > Duration.zero) {
final now = DateTime.now().millisecondsSinceEpoch;
final since = (t.extra['stallSince'] as int?) ?? now;
t.extra['stallSince'] = since;
if (now - since >= after.inMilliseconds && t.extra['stalled'] != true) {
t.extra['stalled'] = true;
t.extra['stallReasons'] = await _stallReasons();
}
} else {
t.extra
..remove('stallSince')
..remove('stalled')
..remove('stallReasons');
}
switch (s['status']) { switch (s['status']) {
case 'complete': case 'complete':
t t
@ -266,3 +319,23 @@ class Aria2Backend implements DownloadBackend {
_client.close(force: true); _client.close(force: true);
} }
} }
/// 28 확인 (10/20): 인터넷에 닿는 IPv6 (전역 2000::/3) 가 있는지. ULA (fc00::/7 - Tailscale fd7a:115c:a1e0:: 포함) ·
/// 링크 로컬 (fe80::) · Teredo (2001:0::/32) · 6to4 (2002::/16) 만 있으면 없음
bool hasInternetIpv6(Iterable<InternetAddress> addrs) => addrs.any((a) {
if (a.type != InternetAddressType.IPv6) return false;
final b = a.rawAddress;
if ((b[0] & 0xE0) != 0x20) return false;
if (b[0] == 0x20 && b[1] == 0x01 && b[2] == 0 && b[3] == 0) return false;
if (b[0] == 0x20 && b[1] == 0x02) return false;
return true;
});
/// 인터넷 IPv4 로 보이는 주소가 있는지 (127/8 · 169.254/16 · Tailscale 100.64/10 은 빼고 - 사설 주소는 공유기 뒤라 넣는다)
bool hasInternetIpv4(Iterable<InternetAddress> addrs) => addrs.any((a) {
if (a.type != InternetAddressType.IPv4) return false;
final b = a.rawAddress;
if (b[0] == 127 || (b[0] == 169 && b[1] == 254)) return false;
if (b[0] == 100 && (b[1] & 0xC0) == 64) return false;
return true;
});

View File

@ -9,6 +9,10 @@ import '../../services/storage_service.dart';
import '../../l10n/tr.dart'; import '../../l10n/tr.dart';
class DesktopStorageService implements StorageService { class DesktopStorageService implements StorageService {
/// [tempDirectory] 의 기준 폴더 (null = 시스템 임시 폴더). 시험이 파일마다 따로 쓰고 끝에 지우게 한다
/// (test/flutter_test_config.dart - 예전에는 시험이 %TEMP%\jj_mkvmaker 를 남겼다, 관리자 지시 10/20)
static String? tempRoot;
@override @override
Future<List<String>> pickVideos() async { Future<List<String>> pickVideos() async {
final files = await FilePicker.pickFiles( final files = await FilePicker.pickFiles(
@ -108,7 +112,7 @@ class DesktopStorageService implements StorageService {
@override @override
Future<String> tempDirectory() async { Future<String> tempDirectory() async {
final dir = Directory(p.join(Directory.systemTemp.path, 'jj_mkvmaker')); final dir = Directory(p.join(tempRoot ?? Directory.systemTemp.path, 'jj_mkvmaker'));
await dir.create(recursive: true); await dir.create(recursive: true);
return dir.path; return dir.path;
} }

View File

@ -23,12 +23,19 @@ class GitHubUpdater implements Updater {
..userAgent = 'JJ_MKVMaker-updater' ..userAgent = 'JJ_MKVMaker-updater'
..connectionTimeout = const Duration(seconds: 20); ..connectionTimeout = const Duration(seconds: 20);
/// 받은 업데이트 · 교체 스크립트 · 기록을 둘 곳 (null = 시스템 임시 폴더). 시험은 시험 폴더를 준다
/// (예전에는 시험이 %TEMP% 에 jj_mkvmaker_update_1.9.0 · .ps1 · .log 를 남겼다, 관리자 지시 10/20)
final String? tempRoot;
GitHubUpdater({ GitHubUpdater({
this.apiBase = 'https://api.github.com', this.apiBase = 'https://api.github.com',
String? appDir, String? appDir,
this.versionOverride, this.versionOverride,
this.tempRoot,
}) : appDir = appDir ?? AppPaths.root; // Lib 구조면 배포 폴더 맨 위 }) : appDir = appDir ?? AppPaths.root; // Lib 구조면 배포 폴더 맨 위
String get _temp => tempRoot ?? Directory.systemTemp.path;
@override @override
bool get installsInPlace => false; bool get installsInPlace => false;
@ -81,7 +88,7 @@ class GitHubUpdater implements Updater {
Future<String> download(ReleaseInfo r, void Function(double progress) onProgress) async { Future<String> download(ReleaseInfo r, void Function(double progress) onProgress) async {
final url = r.zipUrl; final url = r.zipUrl;
if (url == null) throw UpdateException(tr('이 버전에는 Windows 용 zip 이 없습니다.')); if (url == null) throw UpdateException(tr('이 버전에는 Windows 용 zip 이 없습니다.'));
final work = Directory(p.join(Directory.systemTemp.path, 'jj_mkvmaker_update_${r.version}')); final work = Directory(p.join(_temp, 'jj_mkvmaker_update_${r.version}'));
if (await work.exists()) await work.delete(recursive: true); if (await work.exists()) await work.delete(recursive: true);
await work.create(recursive: true); await work.create(recursive: true);
final zip = File(p.join(work.path, r.zipName ?? 'update.zip')); final zip = File(p.join(work.path, r.zipName ?? 'update.zip'));
@ -134,7 +141,7 @@ param([int]$ParentPid, [string]$Source, [string]$Target, [string]$Exe, [switch]$
$ErrorActionPreference = 'Continue' $ErrorActionPreference = 'Continue'
try { Wait-Process -Id $ParentPid -Timeout 90 -ErrorAction Stop } catch {} try { Wait-Process -Id $ParentPid -Timeout 90 -ErrorAction Stop } catch {}
Start-Sleep -Milliseconds 800 Start-Sleep -Milliseconds 800
$log = Join-Path $env:TEMP 'jj_mkvmaker_update.log' $log = Join-Path (Split-Path -Parent $PSCommandPath) 'jj_mkvmaker_update.log'
# keep downloads, models and user data # keep downloads, models and user data
$lib = Join-Path $Source 'Lib' $lib = Join-Path $Source 'Lib'
if (Test-Path $lib) { if (Test-Path $lib) {
@ -154,7 +161,7 @@ exit 0
/// 스크립트 실행. [waitPid] 가 끝나기를 기다렸다가 교체한다. /// 스크립트 실행. [waitPid] 가 끝나기를 기다렸다가 교체한다.
Future<Process> runInstallScript(String source, {required int waitPid, bool restart = true}) async { Future<Process> runInstallScript(String source, {required int waitPid, bool restart = true}) async {
final ps1 = File(p.join(Directory.systemTemp.path, 'jj_mkvmaker_update.ps1')); final ps1 = File(p.join(_temp, 'jj_mkvmaker_update.ps1'));
await ps1.writeAsString(script); await ps1.writeAsString(script);
return Process.start( return Process.start(
'powershell.exe', 'powershell.exe',

View File

@ -29,7 +29,7 @@ class DownloadTask {
final Map<String, Object?> extra = {}; final Map<String, Object?> extra = {};
DownloadTask({required this.id, required this.kind, required this.source, required this.dir}) DownloadTask({required this.id, required this.kind, required this.source, required this.dir})
: title = source; : title = magnetName(source) ?? source; // 마그넷은 메타데이터 전에도 dn 이름으로 (받으면 토렌트 이름으로 바뀜)
bool get unfinished => bool get unfinished =>
state == DownloadState.queued || state == DownloadState.downloading || state == DownloadState.paused; state == DownloadState.queued || state == DownloadState.downloading || state == DownloadState.paused;

View File

@ -107,7 +107,7 @@ class PlatformServices {
fallbackCookieArgs: () => ytDlpCookieArgs( fallbackCookieArgs: () => ytDlpCookieArgs(
browser: internalBrowserCookies, internalCookieFile: s().internalCookieFile), browser: internalBrowserCookies, internalCookieFile: s().internalCookieFile),
), ),
Aria2Backend(), Aria2Backend(stallAfter: () => Duration(seconds: s().torrentStallSeconds)),
], ],
shell: DesktopShell(), shell: DesktopShell(),
updater: GitHubUpdater(), updater: GitHubUpdater(),

View File

@ -19,6 +19,7 @@ import 'rsync_setup.dart';
import 'schedule_editor.dart'; import 'schedule_editor.dart';
import 'theme.dart'; import 'theme.dart';
import 'setting_tile.dart'; import 'setting_tile.dart';
import 'sync_preview_view.dart' show OptionTextFormatter, keepOptionsTogether, optionProblemText;
/// 환경 설정의 복사 · 동기화 부분. /// 환경 설정의 복사 · 동기화 부분.
/// - 파일 탐색기 ([rsync] false): 파일 / 폴더 복사 방법 (현재 방식 · robocopy), robocopy 옵션, 속도 제한 /// - 파일 탐색기 ([rsync] false): 파일 / 폴더 복사 방법 (현재 방식 · robocopy), robocopy 옵션, 속도 제한
@ -75,8 +76,10 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
/// 옵션 입력칸 (바꾸면 바로 저장, ↺ 로 기본값). /// 옵션 입력칸 (바꾸면 바로 저장, ↺ 로 기본값).
/// 213: 예전에는 값이 기본값과 같아지거나 달라지는 순간 칸을 새로 만들어 (↺ 로 되돌릴 때 글을 바꾸려고) 포커스를 잃고 입력이 끊겼다 /// 213: 예전에는 값이 기본값과 같아지거나 달라지는 순간 칸을 새로 만들어 (↺ 로 되돌릴 때 글을 바꾸려고) 포커스를 잃고 입력이 끊겼다
Widget _options(String title, String hint, String value, String def, void Function(AppSettings, String) set) { Widget _options(String title, String hint, String value, String def, void Function(AppSettings, String) set, {bool rsync = false}) {
final ctl = _optionText.putIfAbsent(title, () => TextEditingController(text: value)); final ctl = _optionText.putIfAbsent(title, () => TextEditingController(text: value));
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 rsync 옵션은 칸 아래에 빨갛게 (실행 · 실시간 동기화는 막힘)
final bad = rsync ? optionProblems(ctl.text) : const <OptionProblem>[];
return SettingTile( return SettingTile(
title: Text(title), title: Text(title),
subtitle: Text(hint), subtitle: Text(hint),
@ -84,11 +87,14 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
width: 380, width: 380,
child: TextFormField( child: TextFormField(
controller: ctl, controller: ctl,
inputFormatters: const [OptionTextFormatter()],
scrollPadding: const EdgeInsets.all(80), scrollPadding: const EdgeInsets.all(80),
style: const TextStyle(fontFamily: 'monospace', fontSize: 13), style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
decoration: InputDecoration( decoration: InputDecoration(
isDense: true, isDense: true,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
errorText: bad.isEmpty ? null : optionProblemText(bad),
errorMaxLines: 3,
suffixIcon: IconButton( suffixIcon: IconButton(
tooltip: trf('기본값으로 ({0})', [def]), tooltip: trf('기본값으로 ({0})', [def]),
icon: const Icon(Icons.restart_alt, size: 18), icon: const Icon(Icons.restart_alt, size: 18),
@ -177,7 +183,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
value: c.settings.copySkipNewer, value: c.settings.copySkipNewer,
onChanged: (v) => c.updateSettings((x) => x.copySkipNewer = v), onChanged: (v) => c.updateSettings((x) => x.copySkipNewer = v),
title: Text(tr('받는 쪽이 더 새것이면 건너뛰기')), title: Text(tr('받는 쪽이 더 새것이면 건너뛰기')),
subtitle: Text(tr('현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다')), subtitle: Text(keepOptionsTogether(tr('현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다'))),
); );
Widget _rsyncPart() { Widget _rsyncPart() {
@ -187,7 +193,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
// 111: 이미 실행한 쌍은 기억한 옵션을 쓴다는 것을 알린다 // 111: 이미 실행한 쌍은 기억한 옵션을 쓴다는 것을 알린다
_options(tr('rsync 옵션'), tr('예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 속도 제한은 파일 탐색기의 속도 제한을 함께 씀. ' _options(tr('rsync 옵션'), tr('예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 속도 제한은 파일 탐색기의 속도 제한을 함께 씀. '
'새로 맞추는 폴더 쌍에 씁니다 - 이미 실행한 쌍은 기억한 옵션을 쓰며 모니터링 > 복사 · rsync 에서 따로 고칩니다'), '새로 맞추는 폴더 쌍에 씁니다 - 이미 실행한 쌍은 기억한 옵션을 쓰며 모니터링 > 복사 · rsync 에서 따로 고칩니다'),
s.rsyncOptions, defaultRsyncOptions, (x, v) => x.rsyncOptions = v), s.rsyncOptions, defaultRsyncOptions, (x, v) => x.rsyncOptions = v, rsync: true),
_skipNewerTile(), _skipNewerTile(),
SettingTile( SettingTile(
title: Text(tr('rsync 가져오기')), title: Text(tr('rsync 가져오기')),
@ -336,7 +342,7 @@ class _LiveSyncTile extends StatelessWidget {
value: delete, value: delete,
onChanged: (v) => set(() => delete = v ?? false), onChanged: (v) => set(() => delete = v ?? false),
title: Text(tr('원본에 없는 것을 대상에서 지우기')), title: Text(tr('원본에 없는 것을 대상에서 지우기')),
subtitle: Text(tr('rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만')), subtitle: Text(keepOptionsTogether(tr('rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만'))),
), ),
SettingTile( SettingTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,

View File

@ -315,7 +315,18 @@ class _DownloadRow extends StatelessWidget {
// 136: 실패하면 진행 막대 ("준비 중…") 를 지우고 실패 · 이유 (사람 말) · [자세히] · [다시 시도] // 136: 실패하면 진행 막대 ("준비 중…") 를 지우고 실패 · 이유 (사람 말) · [자세히] · [다시 시도]
if (t.state != DownloadState.cancelled && t.state != DownloadState.failed) DownloadProgressBar(t: t), if (t.state != DownloadState.cancelled && t.state != DownloadState.failed) DownloadProgressBar(t: t),
const SizedBox(height: 4), const SizedBox(height: 4),
if (t.state == DownloadState.failed) // 관리자 지시 (10/20): 시작되지 않는 마그넷 · 토렌트 - 이유 후보와 [다시 시도] (아무 표시 없이 "받는 중" 이던 것)
if (t.state == DownloadState.downloading && t.extra['stalled'] == true)
Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 4, children: [
Text(
'${trf('트래커 · 피어에 닿지 않습니다 ({0}초째)', [((DateTime.now().millisecondsSinceEpoch - ((t.extra['stallSince'] as int?) ?? 0)) / 1000).round()])} · '
'${trf('이유 후보: {0}', [[for (final r in (t.extra['stallReasons'] as List?) ?? const []) stallReasonText('$r')].join(' · ')])}',
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
if (((t.extra['stallReasons'] as List?) ?? const []).contains('dns') && d.useSystemDns != null)
TextButton(style: _small, onPressed: () => d.useSystemDns!(), child: Text(tr('시스템 DNS 로 바꾸기'))),
TextButton(style: _small, onPressed: () => d.retryStalled(t), child: Text(tr('다시 시도'))),
])
else if (t.state == DownloadState.failed)
Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 4, children: [ Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 4, children: [
Text('$label · ${_why(t)}', style: const TextStyle(fontSize: 12, color: JjColors.danger)), Text('$label · ${_why(t)}', style: const TextStyle(fontSize: 12, color: JjColors.danger)),
if ((t.error ?? '').trim().isNotEmpty) if ((t.error ?? '').trim().isNotEmpty)
@ -361,6 +372,13 @@ class _DownloadRow extends StatelessWidget {
} }
} }
/// 멈춘 마그넷 · 토렌트의 이유 후보 글 (Aria2Backend 가 정한 이름)
String stallReasonText(String code) => switch (code) {
'ipv6only' => tr('인터넷 IPv4 가 없음 (IPv6 만)'),
'dns' => tr('DNS 강제 (환경 설정 > 네트워크 · DNS)'),
_ => tr('방화벽 · 공유기가 토렌트를 막음'),
};
/// "받은 크기 / 전체 크기" (다 받았으면 전체 크기만, 모르면 빈 글) /// "받은 크기 / 전체 크기" (다 받았으면 전체 크기만, 모르면 빈 글)
String downloadSizeText(DownloadTask t) { String downloadSizeText(DownloadTask t) {
final got = t.receivedBytes, all = t.totalBytes; final got = t.receivedBytes, all = t.totalBytes;
@ -382,7 +400,11 @@ class DownloadProgressBar extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final done = t.state == DownloadState.done; final done = t.state == DownloadState.done;
final value = done ? 1.0 : t.progress; final value = done ? 1.0 : t.progress;
// 관리자 지시 (10/20, 28 확인): 멈춘 마그넷 · 토렌트 (줄의 주황 "트래커 · 피어에 닿지 않습니다") 는 막대를 움직이지 않고
// 주황으로 "멈춤" - 움직이는 "준비 중…" 이면 받는 중인 것처럼 보였다
final stalled = t.state == DownloadState.downloading && t.extra['stalled'] == true;
final color = switch (t.state) { final color = switch (t.state) {
_ when stalled => Colors.orangeAccent,
DownloadState.done => JjColors.success, DownloadState.done => JjColors.success,
DownloadState.failed => JjColors.danger, DownloadState.failed => JjColors.danger,
DownloadState.paused => Colors.amber, DownloadState.paused => Colors.amber,
@ -396,7 +418,10 @@ class DownloadProgressBar extends StatelessWidget {
? tr('실패') // 136: 실패한 것에 "준비 중…" 이 남지 않게 (작은 다운로드 상자 등) ? tr('실패') // 136: 실패한 것에 "준비 중…" 이 남지 않게 (작은 다운로드 상자 등)
: t.state == DownloadState.queued || t.extra['waiting'] == true : t.state == DownloadState.queued || t.extra['waiting'] == true
? tr('대기 중') ? tr('대기 중')
: tr('준비 중…')) : stalled
// 관리자 지시 (10/20, 28 지적): 영어 "Stopped" 는 일시 정지 · 취소로 읽혀 따로 열쇠 ("Stalled")
? tr('멈춤 (시작 안 됨)')
: tr('준비 중…'))
: '${(value * 100).toStringAsFixed(1)}%${size.isEmpty ? '' : ' · $size'}'; : '${(value * 100).toStringAsFixed(1)}%${size.isEmpty ? '' : ' · $size'}';
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
@ -405,9 +430,10 @@ class DownloadProgressBar extends StatelessWidget {
child: Stack(fit: StackFit.expand, children: [ child: Stack(fit: StackFit.expand, children: [
LinearProgressIndicator( LinearProgressIndicator(
// 진행률을 모를 때(받기 준비 · 마그넷 정보 받는 중)는 움직이는 막대 // 진행률을 모를 때(받기 준비 · 마그넷 정보 받는 중)는 움직이는 막대
value: value ?? (t.state == DownloadState.downloading ? null : 0), value: value ?? (t.state == DownloadState.downloading && !stalled ? null : 0),
minHeight: height, minHeight: height,
backgroundColor: JjColors.panelHigh, // 멈춘 줄은 빈 막대도 주황 기운으로 (회색 빈 막대면 그냥 시작 전처럼 보임)
backgroundColor: stalled ? Colors.orangeAccent.withValues(alpha: 0.28) : JjColors.panelHigh,
color: color.withValues(alpha: 0.85), color: color.withValues(alpha: 0.85),
), ),
Center( Center(

View File

@ -243,6 +243,9 @@ class _Pane extends ChangeNotifier {
final back = <String>[]; final back = <String>[];
final forward = <String>[]; final forward = <String>[];
bool traveling = false; bool traveling = false;
/// 이 창의 목록이 화면에 붙어 있는지 (새로 붙을 때 지금 폴더를 보이게 - [_ExplorerPageState._revealNewViews])
bool onScreen = false;
String? focused; String? focused;
final expanded = <String>{}; final expanded = <String>{};
final cache = <String, List<FileEntry>>{}; final cache = <String, List<FileEntry>>{};
@ -1056,19 +1059,30 @@ class _ExplorerPageState extends State<ExplorerPage> {
} }
/// 그 항목이 목록 위쪽 1/4 쯤에 보이게 스크롤 (깊은 폴더로 가면 위쪽 폴더들의 다른 항목에 밀려 안 보이므로) /// 그 항목이 목록 위쪽 1/4 쯤에 보이게 스크롤 (깊은 폴더로 가면 위쪽 폴더들의 다른 항목에 밀려 안 보이므로)
void _reveal(_Pane pane, String path) { void _reveal(_Pane pane, String path) => WidgetsBinding.instance.addPostFrameCallback((_) => _revealNow(pane, path));
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !pane.scroll.hasClients) return; void _revealNow(_Pane pane, String path) {
// 화면의 트리와 같은 줄로 센다 (폴더 + 파일 목록 배치 · Rsync 화면은 폴더만) if (!mounted || !pane.scroll.hasClients) return;
final i = _rows(pane, foldersOnly: _split || widget.rsync).indexWhere((r) => r.kind == 0 && samePath(r.entry.path, path)); // 화면의 트리와 같은 줄로 센다 (폴더 + 파일 목록 배치 · Rsync 화면은 폴더만)
if (i < 0) return; final i = _rows(pane, foldersOnly: _split || widget.rsync).indexWhere((r) => r.kind == 0 && samePath(r.entry.path, path));
final pos = pane.scroll.position; if (i < 0) return;
// 180: 끝 (maxScrollExtent) 으로 자르지 않는다 - 줄이 막 늘어난 때는 끝이 아직 앞의 것이라 보던 폴더로 못 간다. final pos = pane.scroll.position;
// 넘치면 다음 배치 때 실제 끝으로 맞춰진다 // 180: 끝 (maxScrollExtent) 으로 자르지 않는다 - 줄이 막 늘어난 때는 끝이 아직 앞의 것이라 보던 폴더로 못 간다.
pane.scroll.jumpTo((i * _look.rowHeightOf(context) - pos.viewportDimension / 4).clamp(0.0, double.infinity)); // 넘치면 다음 배치 때 실제 끝으로 맞춰진다
}); pane.scroll.jumpTo((i * _look.rowHeightOf(context) - pos.viewportDimension / 4).clamp(0.0, double.infinity));
} }
/// 28 확인 (10/20): 창의 목록이 화면에 새로 붙으면 (좁은 Rsync 화면의 [오른쪽] 탭을 처음 볼 때 · 폭이 바뀌어 두 창이 될 때)
/// 그 창의 지금 폴더를 보이게 한다. 켤 때 그려지지 않은 창은 [_goTo] 의 스크롤을 건너뛰어, 나중에 보면 맨 위 (저장 장치) 에 있었다
void _revealNewViews() => WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
for (final pane in _panes) {
final on = pane.scroll.hasClients;
if (on && !pane.onScreen && pane.current.isNotEmpty) _revealNow(pane, pane.current);
pane.onScreen = on;
}
});
/// 75: 창마다 뒤로 · 앞으로 (가 본 폴더로) /// 75: 창마다 뒤로 · 앞으로 (가 본 폴더로)
Future<void> _travel(_Pane pane, {required bool forward}) async { Future<void> _travel(_Pane pane, {required bool forward}) async {
// 앞의 이동이 끝나기 전에 또 불리면 (빨리 두 번 · 같은 누름이 두 번 들어옴) 무시 - 내역에서 두 칸이 빠지지 않게 // 앞의 이동이 끝나기 전에 또 불리면 (빨리 두 번 · 같은 누름이 두 번 들어옴) 무시 - 내역에서 두 칸이 빠지지 않게
@ -3418,6 +3432,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
var filterAck = false; var filterAck = false;
// 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 같은 거르기를 따르므로 미리 볼 수 없는 규칙도 똑같이 알린다 // 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 같은 거르기를 따르므로 미리 볼 수 없는 규칙도 똑같이 알린다
List<String> filterLimits() => {for (final t in tasks) ...RsyncFilter.parse(opts(t)).unsupported}.toList(); List<String> filterLimits() => {for (final t in tasks) ...RsyncFilter.parse(opts(t)).unsupported}.toList();
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션이 남으면 [실행] 을 막는다
List<OptionProblem> badOptions() {
final seen = <String>{};
return [for (final t in tasks) ...optionProblems(opts(t)).where((x) => seen.add(x.text))];
}
// 93: 지우는 실행 (--delete 등. 100: WebDAV 도 따른다) 은 비교가 끝나 지울 목록이 보인 뒤에만 // 93: 지우는 실행 (--delete 등. 100: WebDAV 도 따른다) 은 비교가 끝나 지울 목록이 보인 뒤에만
bool deletes() => tasks.any((t) => optionsDelete(opts(t))); bool deletes() => tasks.any((t) => optionsDelete(opts(t)));
// 105: 원본이 대상 안인데 지우기가 들어가면 막는다 - 창 안에서 옵션이 바뀔 때 (원본 파일 지우기) 와 실행 직전에도 본다 // 105: 원본이 대상 안인데 지우기가 들어가면 막는다 - 창 안에서 옵션이 바뀔 때 (원본 파일 지우기) 와 실행 직전에도 본다
@ -3459,7 +3478,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
// 1280×720 등 낮은 화면에서도 보이게: 원본 지우기 체크 · 경고는 비교 목록보다 위에 (178) // 1280×720 등 낮은 화면에서도 보이게: 원본 지우기 체크 · 경고는 비교 목록보다 위에 (178)
if (both) ...[ if (both) ...[
Text(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).'), Text(keepOptionsTogether(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).')),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
if (optionMove) if (optionMove)
Text(tr('양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).'), Text(tr('양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).'),
@ -3474,7 +3493,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
preview = null; preview = null;
previewError = null; previewError = null;
}), }),
title: Text(tr('원본 파일 지우기 (--remove-source-files)')), title: Text(keepOptionsTogether(tr('원본 파일 지우기 (--remove-source-files)'))),
subtitle: Text(tr('대상으로 옮긴 파일을 원본에서 지웁니다 (이동)')), subtitle: Text(tr('대상으로 옮긴 파일을 원본에서 지웁니다 (이동)')),
), ),
if (removeSource) ...[ if (removeSource) ...[
@ -3493,7 +3512,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
], ],
// 72: 무엇이 바뀌는지 파일별로 (→ · ← · 받는 쪽에만 있음 · 지워짐) // 72: 무엇이 바뀌는지 파일별로 (→ · ← · 받는 쪽에만 있음 · 지워짐)
const SizedBox(height: 8), const SizedBox(height: 8),
if (filterLimits().isNotEmpty) if (badOptions().isNotEmpty) OptionProblemNote(badOptions()),
// 관리자 지시 (10/20): [실행] 이 막힐 때는 막히는 이유 한 줄만 (미리 볼 수 없음 · [알고 실행합니다] 는 숨김)
if (badOptions().isEmpty && filterLimits().isNotEmpty)
FilterLimitNote(unsupported: filterLimits(), deletes: deletes(), ack: filterAck, onAck: (v) => set(() => filterAck = v)), FilterLimitNote(unsupported: filterLimits(), deletes: deletes(), ack: filterAck, onAck: (v) => set(() => filterAck = v)),
SyncPreviewView( SyncPreviewView(
// 옵션이 바뀌면 (원본 파일 지우기) 다시 비교 // 옵션이 바뀌면 (원본 파일 지우기) 다시 비교
@ -3552,7 +3573,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
final dels = preview?.where((x) => x.action == PreviewAction.delete).length ?? 0; final dels = preview?.where((x) => x.action == PreviewAction.delete).length ?? 0;
return FilledButton( return FilledButton(
style: dels > 0 ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null, style: dels > 0 ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null,
onPressed: nestedDelete() || (deletes() && preview == null) || (deletes() && filterLimits().isNotEmpty && !filterAck) onPressed: nestedDelete() || badOptions().isNotEmpty || (deletes() && preview == null) || (deletes() && filterLimits().isNotEmpty && !filterAck)
? null ? null
: () => Navigator.pop(ctx, true), : () => Navigator.pop(ctx, true),
// 110: 글자는 늘 같게 (비교가 끝나 글이 길어지면 버튼 배치가 바뀌어 [취소] 를 누르려다 [실행] 을 누르게 됨). // 110: 글자는 늘 같게 (비교가 끝나 글이 길어지면 버튼 배치가 바뀌어 [취소] 를 누르려다 [실행] 을 누르게 됨).
@ -4404,6 +4425,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
final dual = _dual; final dual = _dual;
final split = _split; final split = _split;
return LayoutBuilder(builder: (context, box) { return LayoutBuilder(builder: (context, box) {
// 폭이 바뀌면 (좁은 한 창 ↔ 두 창) 이 builder 만 다시 불린다 - 그때도 새로 붙은 창을 보이게
if (_ready) _revealNewViews();
final side = !(dual || split) || final side = !(dual || split) ||
switch (s.explorerOrientation) { switch (s.explorerOrientation) {
'side' => true, 'side' => true,

View File

@ -21,7 +21,7 @@ import 'path_label.dart';
import 'rsync_setup.dart'; import 'rsync_setup.dart';
import 'schedule_editor.dart'; import 'schedule_editor.dart';
import '../core/sync_preview.dart' show optionsRemoveSource; import '../core/sync_preview.dart' show optionsRemoveSource;
import 'sync_preview_view.dart' show confirmDeletingRun, effectiveRunText; import 'sync_preview_view.dart' show FilterLimitNote, OptionProblemNote, OptionTextFormatter, confirmDeletingRun, effectiveRunText, keepOptionsTogether, unbreakableOption;
import 'theme.dart'; import 'theme.dart';
/// 모니터링 (파일 탐색기 > 모니터링): 복사 · rsync 와 lsync (실시간 동기화) 를 따로 보여 준다. /// 모니터링 (파일 탐색기 > 모니터링): 복사 · rsync 와 lsync (실시간 동기화) 를 따로 보여 준다.
@ -80,61 +80,87 @@ class MonitorPage extends StatelessWidget {
Future<void> confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPair x) async { Future<void> confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPair x) async {
final list = live.toDelete[LiveSync.keyOf(x)] ?? const <String>[]; final list = live.toDelete[LiveSync.keyOf(x)] ?? const <String>[];
if (list.isEmpty) return; if (list.isEmpty) return;
// 관리자 3a 조건 (10/20): --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 은 따로 묶어 알고 누르게 final pick = await _liveDeleteDialog(context, live, x, list, live.toDeleteExcluded[LiveSync.keyOf(x)] ?? const [], now: false);
final excl = (live.toDeleteExcluded[LiveSync.keyOf(x)] ?? const <String>[]).toSet();
final plain = [for (final n in list) if (!excl.contains(n)) n];
final filtered = [for (final n in list) if (excl.contains(n)) n];
final pick = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(trf('대상에서 {0}개를 지울까요?', [list.length])),
content: SizedBox(
width: 560,
// 세로 폰에서도 목록 (특히 거른 것 묶음) 이 더 보이게 화면 높이를 따라 (360 ~ 520)
height: (MediaQuery.sizeOf(ctx).height * 0.55).clamp(360.0, 520.0),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// 관리자 3a 판단 (10/20): 확인한 쌍인데 옵션이 바뀌어 지울 것이 늘어 다시 묻는 것
if (live.reasked.contains(LiveSync.keyOf(x))) ...[
Text(tr('지우기를 확인한 뒤 rsync 옵션이 바뀌어 지울 것이 늘었습니다. 다시 확인해 주세요.'),
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.orangeAccent)),
const SizedBox(height: 6),
],
Text(trf('"{0}" 에는 없고 대상 "{1}" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).',
[vDisplay(x.source), vDisplay(x.target)])),
const SizedBox(height: 8),
Expanded(
child: ListView(children: [
for (final n in plain.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')),
if (plain.length > 500) Text(trf('… 외 {0}개', [plain.length - 500])),
if (filtered.isNotEmpty) ...[
if (plain.isNotEmpty) const SizedBox(height: 10),
Text(trf('거른 것 {0}개도 지워집니다 (--delete-excluded)', [filtered.length]),
style: const TextStyle(fontWeight: FontWeight.w600, color: JjColors.danger)),
Text(tr('rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 4),
for (final n in filtered.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas', color: JjColors.danger)),
if (filtered.length > 500) Text(trf('… 외 {0}개', [filtered.length - 500])),
],
]),
),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('나중에'))),
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('지우지 않기 (지우기 끄기)'))),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: JjColors.danger),
onPressed: () => Navigator.pop(ctx, true),
child: Text(trf('{0}개 지우고 맞추기', [list.length])),
),
],
),
);
if (pick == null) return; if (pick == null) return;
await live.decideDelete(x, delete: pick); await live.decideDelete(x, delete: pick);
} }
/// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙이 있어 자동으로 지우지 않는 쌍의 [지금 지우기 (확인)].
/// 빨간 FilterLimitNote 와 [알고 실행합니다] 체크를 거쳐 이번 한 번만 지운다 (다음 자동 맞추기는 다시 보내기만)
Future<void> confirmLiveDeleteNow(BuildContext context, LiveSync live, LiveSyncPair x) async {
final (list, excl) = await live.previewDeletes(x);
if (!context.mounted) return;
final pick = await _liveDeleteDialog(context, live, x, list, excl, now: true);
if (pick == true) await live.deleteNow(x);
}
Future<bool?> _liveDeleteDialog(BuildContext context, LiveSync live, LiveSyncPair x, List<String> list, List<String> exclIn,
{required bool now}) {
// 관리자 3a 조건 (10/20): --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 은 따로 묶어 알고 누르게
final excl = exclIn.toSet();
final plain = [for (final n in list) if (!excl.contains(n)) n];
final filtered = [for (final n in list) if (excl.contains(n)) n];
// P0: 미리 볼 수 없는 규칙 - 목록이 실제와 다를 수 있음을 빨갛게, 체크해야 [지우고 맞추기]
final limits = live.unsupportedOf(x);
var ack = false;
return showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
title: Text(list.isEmpty ? tr('대상에서 지울까요?') : trf('대상에서 {0}개를 지울까요?', [list.length])),
content: SizedBox(
width: 560,
// 세로 폰에서도 목록 (특히 거른 것 묶음) 이 더 보이게 화면 높이를 따라 (360 ~ 520)
height: (MediaQuery.sizeOf(ctx).height * 0.55).clamp(360.0, 520.0),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (limits.isNotEmpty) ...[
FilterLimitNote(unsupported: limits, deletes: true, ack: ack, onAck: (v) => set(() => ack = v)),
const SizedBox(height: 4),
],
// 관리자 3a 판단 (10/20): 확인한 쌍인데 옵션이 바뀌어 지울 것이 늘어 다시 묻는 것
if (!now && live.reasked.contains(LiveSync.keyOf(x))) ...[
Text(tr('지우기를 확인한 뒤 rsync 옵션이 바뀌어 지울 것이 늘었습니다. 다시 확인해 주세요.'),
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.orangeAccent)),
const SizedBox(height: 6),
],
Text(trf('"{0}" 에는 없고 대상 "{1}" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).',
[vDisplay(x.source), vDisplay(x.target)])),
const SizedBox(height: 8),
Expanded(
child: ListView(children: [
if (list.isEmpty)
Text(tr('미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
for (final n in plain.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')),
if (plain.length > 500) Text(trf('… 외 {0}개', [plain.length - 500])),
if (filtered.isNotEmpty) ...[
if (plain.isNotEmpty) const SizedBox(height: 10),
Text(keepOptionsTogether(trf('거른 것 {0}개도 지워집니다 (--delete-excluded)', [filtered.length])),
style: const TextStyle(fontWeight: FontWeight.w600, color: JjColors.danger)),
Text(keepOptionsTogether(tr('rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.')),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 4),
for (final n in filtered.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas', color: JjColors.danger)),
if (filtered.length > 500) Text(trf('… 외 {0}개', [filtered.length - 500])),
],
]),
),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('나중에'))),
if (!now) TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('지우지 않기 (지우기 끄기)'))),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: JjColors.danger),
onPressed: limits.isNotEmpty && !ack ? null : () => Navigator.pop(ctx, true),
child: Text(list.isEmpty ? tr('지우고 맞추기') : trf('{0}개 지우고 맞추기', [list.length])),
),
],
),
),
);
}
/// 70: 원본이 비어 멈춘 쌍 - 지워질 목록을 보여 주고 [그래도 맞추기] 로 확인받는다 (42 와 같은 창) /// 70: 원본이 비어 멈춘 쌍 - 지워질 목록을 보여 주고 [그래도 맞추기] 로 확인받는다 (42 와 같은 창)
Future<void> confirmEmptySourceSync(BuildContext context, LiveSync live, LiveSyncPair x) async { Future<void> confirmEmptySourceSync(BuildContext context, LiveSync live, LiveSyncPair x) async {
final list = live.emptyDeletes[LiveSync.keyOf(x)] ?? const <String>[]; final list = live.emptyDeletes[LiveSync.keyOf(x)] ?? const <String>[];
@ -621,6 +647,7 @@ class _CopyCardState extends State<_CopyCard> {
width: 320, width: 320,
child: TextField( child: TextField(
controller: _options, controller: _options,
inputFormatters: const [OptionTextFormatter()],
enabled: _method != 'builtin', enabled: _method != 'builtin',
style: const TextStyle(fontFamily: 'monospace', fontSize: 13), style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
decoration: InputDecoration(isDense: true, border: const OutlineInputBorder(), labelText: tr('옵션'), hintText: optsHint), decoration: InputDecoration(isDense: true, border: const OutlineInputBorder(), labelText: tr('옵션'), hintText: optsHint),
@ -635,6 +662,8 @@ class _CopyCardState extends State<_CopyCard> {
'${t.move ? tr('이동') : tr('복사')} · ${trf('실제 실행: {0}', [effectiveRunText(_method, _options.text.trim(), move: t.move, skipNewer: s.copySkipNewer)])}', '${t.move ? tr('이동') : tr('복사')} · ${trf('실제 실행: {0}', [effectiveRunText(_method, _options.text.trim(), move: t.move, skipNewer: s.copySkipNewer)])}',
style: TextStyle(fontSize: 12, color: t.move ? Colors.orangeAccent : JjColors.textDim), style: TextStyle(fontSize: 12, color: t.move ? Colors.orangeAccent : JjColors.textDim),
), ),
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션 (실행은 작업이 막는다)
if (_method == 'rsync' && optionProblems(_options.text).isNotEmpty) OptionProblemNote(optionProblems(_options.text)),
if (!t.move && optionsRemoveSource(_options.text)) if (!t.move && optionsRemoveSource(_options.text))
Text(tr('복사 작업이라 칸의 원본 지우기 옵션 (--remove-source-files · /MOV) 은 쓰지 않습니다. 옮기려면 Rsync 화면에서 [원본 파일 지우기] 를 켜세요'), Text(tr('복사 작업이라 칸의 원본 지우기 옵션 (--remove-source-files · /MOV) 은 쓰지 않습니다. 옮기려면 Rsync 화면에서 [원본 파일 지우기] 를 켜세요'),
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
@ -669,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('취소'))) TextButton.icon(onPressed: () => center.cancel(t.id), icon: const Icon(Icons.stop, size: 18), label: Text(tr('취소')))
else else
FilledButton.icon( 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), icon: const Icon(Icons.play_arrow, size: 18),
label: Text(t.lastResult == 'cancelled' || t.lastResult == 'failed' ? tr('재개') : tr('실행')), label: Text(t.lastResult == 'cancelled' || t.lastResult == 'failed' ? tr('재개') : tr('실행')),
), ),
@ -943,6 +973,18 @@ class _LiveTabState extends State<_LiveTab> {
final active = LiveSync.activeNow(x); final active = LiveSync.activeNow(x);
final running = live?.isRunning(x) ?? false; final running = live?.isRunning(x) ?? false;
final held = live?.isPaused(x) ?? false; final held = live?.isPaused(x) ?? false;
// 257: 칩은 길게 누르기 · 마우스로 전체를 본다. 관리자 지시 (10/20, 28 지적): 좁은 화면 (폰 세로) 에서는 경로 줄에 끼면
// "rsync · 복…" 으로 잘려 경로 아래 따로 한 줄로 (넓으면 예전처럼 경로 옆)
final narrow = MediaQuery.sizeOf(context).width < 600;
final chipText = [tr(CopyMethod.of(x.method).label), tr('복사로 맞춤'), if (x.delete) tr('지우기 포함')].join(' · ');
final chip = Tooltip(
message: chipText,
child: Chip(
visualDensity: VisualDensity.compact,
// 243: 실시간 동기화는 늘 복사로 맞춘다 (원본은 지우지 않음 - 원본을 비우려면 [최종 정리])
label: Text(chipText, maxLines: 1, overflow: TextOverflow.ellipsis),
),
);
return Card( return Card(
color: JjColors.panel, color: JjColors.panel,
margin: const EdgeInsets.only(bottom: 10), margin: const EdgeInsets.only(bottom: 10),
@ -980,17 +1022,7 @@ class _LiveTabState extends State<_LiveTab> {
), ),
), ),
// 257: 폰 세로에서 이 줄이 넘쳤다 - 칩은 남는 폭만큼 (넘치면 … 로 줄임, 전체는 길게 누르기 · 마우스) // 257: 폰 세로에서 이 줄이 넘쳤다 - 칩은 남는 폭만큼 (넘치면 … 로 줄임, 전체는 길게 누르기 · 마우스)
Flexible( if (!narrow) Flexible(child: chip),
child: Tooltip(
message: [tr(CopyMethod.of(x.method).label), tr('복사로 맞춤'), if (x.delete) tr('지우기 포함')].join(' · '),
child: Chip(
visualDensity: VisualDensity.compact,
// 243: 실시간 동기화는 늘 복사로 맞춘다 (원본은 지우지 않음 - 원본을 비우려면 [최종 정리])
label: Text([tr(CopyMethod.of(x.method).label), tr('복사로 맞춤'), if (x.delete) tr('지우기 포함')].join(' · '),
maxLines: 1, overflow: TextOverflow.ellipsis),
),
),
),
// 이번 실행 동안 멈춤 / 시작 (설정의 켜기 · 끄기는 오른쪽 스위치) // 이번 실행 동안 멈춤 / 시작 (설정의 켜기 · 끄기는 오른쪽 스위치)
if (live != null && x.enabled) if (live != null && x.enabled)
IconButton( IconButton(
@ -1000,6 +1032,7 @@ class _LiveTabState extends State<_LiveTab> {
), ),
Switch(value: x.enabled, onChanged: (v) => _replace(x, x.copyWith(enabled: v))), Switch(value: x.enabled, onChanged: (v) => _replace(x, x.copyWith(enabled: v))),
]), ]),
if (narrow) Padding(padding: const EdgeInsets.only(left: 32, top: 2), child: chip),
if (held) if (held)
Padding( Padding(
padding: const EdgeInsets.only(left: 32, top: 2), padding: const EdgeInsets.only(left: 32, top: 2),
@ -1020,6 +1053,21 @@ class _LiveTabState extends State<_LiveTab> {
), ),
]), ]),
), ),
// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙 → 저절로 지우지 않고 보내기만. 지우기는 확인을 거쳐 한 번씩
if (live != null && live.noAutoDelete[k] != null)
Padding(
padding: const EdgeInsets.only(left: 32, top: 4),
child: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
const Icon(Icons.block, size: 16, color: Colors.redAccent),
Text(tr('이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다'),
style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)),
Text('(${live.noAutoDelete[k]!.map(unbreakableOption).join(' · ')})', style: const TextStyle(fontSize: 12, color: Colors.redAccent)),
OutlinedButton(
onPressed: () => confirmLiveDeleteNow(context, live, x),
child: Text(tr('지금 지우기 (확인)')),
),
]),
),
Padding( Padding(
padding: const EdgeInsets.only(left: 32, top: 4), padding: const EdgeInsets.only(left: 32, top: 4),
child: Wrap(spacing: 16, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [ child: Wrap(spacing: 16, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [

View File

@ -45,6 +45,7 @@ import 'toast_status.dart';
import 'license_texts.dart'; import 'license_texts.dart';
import 'subtitle_language_picker.dart'; import 'subtitle_language_picker.dart';
import 'hidden_items_dialog.dart'; import 'hidden_items_dialog.dart';
import '../core/remote_idle.dart';
/// 동시 작업 수 고르기: 1 · 5 · 10 · 무한(0) · 직접 입력 /// 동시 작업 수 고르기: 1 · 5 · 10 · 무한(0) · 직접 입력
class CountSelector extends StatelessWidget { class CountSelector extends StatelessWidget {
@ -926,6 +927,20 @@ class _SettingsPageState extends State<SettingsPage> {
FtpSettings(c: c), FtpSettings(c: c),
// 88: SMB (Android 만 - Windows 는 운영체제가 \\서버\공유 를 연다) // 88: SMB (Android 만 - Windows 는 운영체제가 \\서버\공유 를 연다)
if (Platform.isAndroid) SmbSettings(c: c), if (Platform.isAndroid) SmbSettings(c: c),
// 관리자 판단 (10/20): 원격 서버가 멈췄을 때 기다리는 시간
SettingTile(
leading: const Icon(Icons.hourglass_bottom_outlined),
title: Text(tr('원격 서버 응답 기다리기')),
subtitle: Text(tr('WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)')),
trailing: DropdownButton<int>(
value: RemoteIdle.choices.contains(s.remoteIdleSeconds) ? s.remoteIdleSeconds : RemoteIdle.defaultSeconds,
items: [
for (final n in RemoteIdle.choices)
DropdownMenuItem(value: n, child: Text(n == RemoteIdle.defaultSeconds ? trf('{0}초 (기본)', [n]) : trf('{0}초', [n]))),
],
onChanged: (v) => c.updateSettings((x) => x.remoteIdleSeconds = v!),
),
),
ViewerSettings(c: c), ViewerSettings(c: c),
]), ]),
// Rsync 화면: rsync 옵션 · 가져오기 · 실시간 동기화 (lsync) · 백그라운드로 실행 // Rsync 화면: rsync 옵션 · 가져오기 · 실시간 동기화 (lsync) · 백그라운드로 실행
@ -961,6 +976,19 @@ class _SettingsPageState extends State<SettingsPage> {
title: Text(tr('완료 시 자동 동영상 추가')), title: Text(tr('완료 시 자동 동영상 추가')),
subtitle: Text(tr('다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)')), subtitle: Text(tr('다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)')),
), ),
// 관리자 지시 (10/20): 시작되지 않는 마그넷 · 토렌트 알림 (처음 60초)
SettingTile(
title: Text(tr('마그넷 · 토렌트가 시작되지 않으면 알리기')),
subtitle: Text(tr('메타데이터나 피어를 이 시간 동안 못 받으면 그 줄에 이유 후보와 [다시 시도] 를 보입니다')),
trailing: DropdownButton<int>(
value: s.torrentStallSeconds,
items: [
for (final v in const [30, 60, 120, 300]) DropdownMenuItem(value: v, child: Text(trf('{0}초', [v]))),
DropdownMenuItem(value: 0, child: Text(tr('알리지 않음'))),
],
onChanged: (v) => c.updateSettings((x) => x.torrentStallSeconds = v!),
),
),
SettingTile( SettingTile(
title: Text(tr('YouTube 받을 형식')), title: Text(tr('YouTube 받을 형식')),
subtitle: Text(tr('기본: MP4. 음성만 받으면 MP3 · M4A 로 저장')), subtitle: Text(tr('기본: MP4. 음성만 받으면 MP3 · M4A 로 저장')),

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../app/settings.dart' show CopyTask; import '../app/settings.dart' show CopyTask;
import '../core/file_ops.dart' show formatSize, isPartialFile; import '../core/file_ops.dart' show formatSize, isPartialFile;
@ -125,7 +126,7 @@ class _SyncPreviewViewState extends State<SyncPreviewView> {
IconButton(tooltip: tr('다시 비교'), icon: const Icon(Icons.refresh, size: 18), onPressed: _run), IconButton(tooltip: tr('다시 비교'), icon: const Icon(Icons.refresh, size: 18), onPressed: _run),
]), ]),
if (dels > 0) if (dels > 0)
Text(trf('받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)', [dels]), Text(keepOptionsTogether(trf('받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)', [dels])),
style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)), style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)),
// 15 검토 F4: 받는 쪽에만 있는 받다 만 파일 (*.jjpart 등) 도 지워진다는 것을 따로 // 15 검토 F4: 받는 쪽에만 있는 받다 만 파일 (*.jjpart 등) 도 지워진다는 것을 따로
if (partials > 0) if (partials > 0)
@ -227,6 +228,7 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
// 177: rsync 로 맞출 때만 옵션의 거르기 (--exclude 등) 를 비교에도 (앱이 직접 · robocopy 는 이 규칙을 쓰지 않음) // 177: rsync 로 맞출 때만 옵션의 거르기 (--exclude 등) 를 비교에도 (앱이 직접 · robocopy 는 이 규칙을 쓰지 않음)
// 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 rsync 를 고른 작업이면 같은 거르기를 따른다 (현재 방식 · robocopy 는 이 규칙을 쓰지 않음) // 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 rsync 를 고른 작업이면 같은 거르기를 따른다 (현재 방식 · robocopy 는 이 규칙을 쓰지 않음)
final filter = t.method == 'rsync' ? RsyncFilter.parse(t.options) : RsyncFilter.none; final filter = t.method == 'rsync' ? RsyncFilter.parse(t.options) : RsyncFilter.none;
final bad = t.method == 'rsync' ? optionProblems(t.options) : const <OptionProblem>[];
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => StatefulBuilder( builder: (ctx) => StatefulBuilder(
@ -255,7 +257,9 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// 15 검토 F2: 미리 볼 수 없는 거르기 규칙 (늘 같은 자리 - 비교 결과와 상관없이 옵션으로 정해짐) // 15 검토 F2: 미리 볼 수 없는 거르기 규칙 (늘 같은 자리 - 비교 결과와 상관없이 옵션으로 정해짐)
if (filter.unsupported.isNotEmpty) if (bad.isNotEmpty) OptionProblemNote(bad),
// 관리자 지시 (10/20): [실행] 이 막힐 때는 막히는 이유 한 줄만 (미리 볼 수 없음 · [알고 실행합니다] 는 숨김)
if (bad.isEmpty && filter.unsupported.isNotEmpty)
FilterLimitNote(unsupported: filter.unsupported, deletes: deletes, ack: filterAck, onAck: (v) => set(() => filterAck = v)), FilterLimitNote(unsupported: filter.unsupported, deletes: deletes, ack: filterAck, onAck: (v) => set(() => filterAck = v)),
SyncPreviewView( SyncPreviewView(
compute: () async => [ compute: () async => [
@ -294,7 +298,9 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton( FilledButton(
style: dels > 0 || moves ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null, style: dels > 0 || moves ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null,
onPressed: preview == null || (deletes && filter.unsupported.isNotEmpty && !filterAck) ? null : () => Navigator.pop(ctx, true), onPressed: preview == null || bad.isNotEmpty || (deletes && filter.unsupported.isNotEmpty && !filterAck)
? null
: () => Navigator.pop(ctx, true),
child: Text(tr('실행')), // 110: 글자를 바꾸지 않는다 (버튼 자리가 움직이지 않게) child: Text(tr('실행')), // 110: 글자를 바꾸지 않는다 (버튼 자리가 움직이지 않게)
), ),
], ],
@ -307,6 +313,59 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
/// 15 검토 F2 (177): rsync 옵션에 미리 볼 수 없는 거르기 규칙 (고치개 · H · P · merge · -F · -C · 읽지 못한 파일 등) 이 있으면 /// 15 검토 F2 (177): rsync 옵션에 미리 볼 수 없는 거르기 규칙 (고치개 · H · P · merge · -F · -C · 읽지 못한 파일 등) 이 있으면
/// 지울 목록이 실제보다 적게 보일 수 있다 - 빨갛게 알리고, 지우기 (--delete) 와 함께면 체크해야 [실행] 이 켜진다 (72 · 104 와 같게) /// 지울 목록이 실제보다 적게 보일 수 있다 - 빨갛게 알리고, 지우기 (--delete) 와 함께면 체크해야 [실행] 이 켜진다 (72 · 104 와 같게)
/// 옵션 글 (예: --filter=H a.txt) 이 좁은 창에서 "--" 와 "filter=…" 로 갈리지 않게 줄바꿈 없이 묶는다 (관리자 지시 10/20, 28 지적):
/// 빈칸은 줄바꿈 없는 빈칸, - · / 뒤에는 줄바꿈을 막는 글자 (U+2060, 보이지 않음)
String unbreakableOption(String s) => s.replaceAll(' ', '\u00A0').replaceAllMapped(RegExp(r'[-/]'), (m) => '${m[0]}\u2060');
/// 28 확인 (10/20): 글 안의 옵션 이름만 묶는다 - 괄호째 ("(--remove-source-files)" · "(--delete, 되돌릴 수 없음)") 와 따로 쓴
/// "--delete-excluded" · "-u" · "/PURGE". 예전에는 en 412 에서 "(--" 에서 갈렸다. 나머지 글은 그대로 줄바꿈
String keepOptionsTogether(String s) => s
.replaceAllMapped(RegExp(r'\([-/][^()]*\)'), (m) => unbreakableOption(m[0]!))
.replaceAllMapped(RegExp(r'(?<![\w\-\u2060/])(?:--?[A-Za-z][\w-]*(?:=\S*)?|/[A-Z]{2,}\b)'), (m) => unbreakableOption(m[0]!));
/// 옵션 입력 칸: 붙여 넣거나 친 글의 U+00A0 · U+2060 등을 고쳐 받는다 ([cleanOptionText], 관리자 지시 10/20).
/// 화면의 묶은 옵션 글 ([unbreakableOption]) 이나 다른 곳의 글을 붙여 넣어도 rsync 가 다르게 읽지 않게
class OptionTextFormatter extends TextInputFormatter {
const OptionTextFormatter();
@override
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
final text = cleanOptionText(newValue.text);
if (text == newValue.text) return newValue;
// 커서 · 고른 곳은 앞부분을 고친 길이로 옮긴다
int at(int i) => i < 0 ? i : cleanOptionText(newValue.text.substring(0, i.clamp(0, newValue.text.length))).length;
return TextEditingValue(
text: text,
selection: TextSelection(baseOffset: at(newValue.selection.baseOffset), extentOffset: at(newValue.selection.extentOffset)),
);
}
}
/// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션 - 빨갛게 알리고 [실행] 을 막는다 (rsync 가 원본 경로로 읽어 옵션이 몰래 빠짐)
/// 알아볼 수 없는 옵션 안내 글 (확인 창 · 설정 칸): 옵션 낱말은 줄바꿈 없이 묶고 (28 지적: "--" 와 "exclude" 로 갈림),
/// 고칠 방법의 - · -- 뒤에서도 줄을 바꾸지 않는다 (나머지 글은 보통처럼 줄을 바꾼다)
String optionProblemText(List<OptionProblem> bad) {
final wj = String.fromCharCode(0x2060);
return trf('옵션을 알아볼 수 없습니다: {0} - {1}', [
bad.map((x) => x.token.isEmpty ? x.text : x.text.replaceFirst(x.token, unbreakableOption(x.token))).join(' · '),
optionProblemHint(bad).replaceAll('-', '-$wj'),
]);
}
class OptionProblemNote extends StatelessWidget {
final List<OptionProblem> bad;
const OptionProblemNote(this.bad, {super.key});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
// 28 지적 (10/20): 끝 문구는 문제 종류에 맞게 (따옴표 닫기 · - 로 시작 · 값 적기 · 곧은 글자로)
optionProblemText(bad),
style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)),
);
}
class FilterLimitNote extends StatelessWidget { class FilterLimitNote extends StatelessWidget {
final List<String> unsupported; final List<String> unsupported;
final bool deletes; final bool deletes;
@ -316,7 +375,7 @@ class FilterLimitNote extends StatelessWidget {
@override @override
Widget build(BuildContext context) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Widget build(BuildContext context) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(trf('이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}', [unsupported.join(' · ')]), Text(trf('이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}', [unsupported.map(unbreakableOption).join(' · ')]),
style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)), style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)),
if (deletes) if (deletes)
CheckboxListTile( CheckboxListTile(

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 # 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 # 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. # 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: environment:
sdk: ^3.13.4 sdk: ^3.13.4

View File

@ -0,0 +1,89 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/download_manager.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/download_detect.dart';
import 'package:jj_mkvmaker/platform/windows/aria2_backend.dart';
import 'package:jj_mkvmaker/services/downloader.dart';
import 'package:jj_mkvmaker/ui/downloads_page.dart';
class _Backend implements DownloadBackend {
@override
DownloadKind get kind => DownloadKind.torrent;
final started = <String>[];
var cancelled = 0;
@override
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => null;
@override
Future<void> start(DownloadTask t, void Function() changed) async {
started.add(t.source);
t.state = DownloadState.downloading;
}
@override
Future<void> pause(DownloadTask t) async {}
@override
Future<void> cancel(DownloadTask t) async {
cancelled++;
t.state = DownloadState.cancelled;
}
@override
Future<void> shutdown() async {}
}
/// 28 확인 (10/20): 마그넷이 이 PC (IPv6 는 Tailscale fd7a:… 뿐) 에서 시작되지 않던 것 - 인터넷 IPv6 판단 · 멈춘 줄 안내
void main() {
InternetAddress a(String s) => InternetAddress(s);
test('인터넷 IPv6: 전역 2000::/3 만 - Tailscale (fd7a:115c:a1e0::) · ULA · 링크 로컬 · Teredo · 6to4 는 아님', () {
expect(hasInternetIpv6([a('fd7a:115c:a1e0::1234')]), isFalse, reason: 'Tailscale 뿐');
expect(hasInternetIpv6([a('fe80::1'), a('fdab::5')]), isFalse);
expect(hasInternetIpv6([a('2001:0:4136:e378::1')]), isFalse, reason: 'Teredo');
expect(hasInternetIpv6([a('2002:c000:204::1')]), isFalse, reason: '6to4');
expect(hasInternetIpv6([a('fd7a:115c:a1e0::1'), a('2406:5900:1:2::10')]), isTrue);
expect(hasInternetIpv6([a('192.168.0.5')]), isFalse);
});
test('인터넷 IPv4: 루프백 · 169.254 · Tailscale 100.64/10 은 빼고 (사설 주소는 공유기 뒤라 넣음)', () {
expect(hasInternetIpv4([a('127.0.0.1'), a('169.254.3.4'), a('100.80.195.34')]), isFalse);
expect(hasInternetIpv4([a('192.168.88.105')]), isTrue);
expect(hasInternetIpv4([a('100.128.0.1')]), isTrue, reason: '100.64/10 밖');
});
test('설정: 시작되지 않으면 알리기 - 처음 60초, 모르는 값은 60', () {
expect(AppSettings().torrentStallSeconds, 60);
expect(AppSettings.fromJson({'torrentStallSeconds': 0}).torrentStallSeconds, 0);
expect(AppSettings.fromJson({'torrentStallSeconds': 7}).torrentStallSeconds, 60);
});
testWidgets('멈춘 마그넷 줄: "트래커 · 피어에 닿지 않습니다" · 이유 후보 · [다시 시도] (지금 것을 지우고 새로 넣음)', (tester) async {
final b = _Backend();
final s = AppSettings()..dnsMode = 'custom';
final d = DownloadManager(backends: [b], settings: () => s);
final t = d.add('magnet:?xt=urn:btih:7acf8fb590b2060dd9c3146ef770169d593433b0')!;
await tester.pump();
t.state = DownloadState.downloading;
t.extra
..['gid'] = 'abc'
..['stallSince'] = DateTime.now().millisecondsSinceEpoch - 61000
..['stalled'] = true
..['stallReasons'] = ['ipv6only', 'firewall', 'dns'];
await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d)));
await tester.pump();
// 61초 전부터 멈춤 - 시험이 느리면 (전체 시험 부하) 몇 초 더 지난다
expect(find.textContaining(RegExp(r'트래커 · 피어에 닿지 않습니다 \((6[1-9]|[7-9]\d)초째\)')), findsOneWidget);
expect(find.textContaining('인터넷 IPv4 가 없음 (IPv6 만) · 방화벽 · 공유기가 토렌트를 막음 · DNS 강제'), findsOneWidget);
await tester.tap(find.text('다시 시도'));
await tester.pump();
expect(b.cancelled, 1);
expect(t.extra.containsKey('gid'), isFalse, reason: '새로 추가');
expect(t.extra.containsKey('stalled'), isFalse);
await tester.pump(const Duration(milliseconds: 100));
expect(b.started, hasLength(2), reason: '다시 시작');
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(seconds: 1));
});
}

View File

@ -143,4 +143,35 @@ void main() {
expect(Cleaner.sizeText(5 * 1024 * 1024), '5.0 MB'); expect(Cleaner.sizeText(5 * 1024 * 1024), '5.0 MB');
expect(Cleaner.sizeText(3 * 1024 * 1024 * 1024), '3.0 GB'); expect(Cleaner.sizeText(3 * 1024 * 1024 * 1024), '3.0 GB');
}); });
test('관리자 지시 (10/20): 앱이 켜질 때 지난 업데이트가 남긴 것 (풀어 둔 새 판 · .ps1 · .log) 만 지운다 - 막 생긴 것 · 다른 jj 이름은 남김', () {
final tmp = Directory.systemTemp.createTempSync('jj_upd_left_');
addTearDown(() => tmp.deleteSync(recursive: true));
final old = DateTime.now().subtract(const Duration(hours: 1));
void make(String rel, {bool fresh = false}) {
final f = File(p.join(tmp.path, rel))..createSync(recursive: true);
if (!fresh) f.setLastModifiedSync(old);
}
make(p.join('jj_mkvmaker_update_2026.10.18_979', 'extract', 'jj_mkvmaker.exe'));
make('jj_mkvmaker_update.ps1');
make('jj_mkvmaker_update.log', fresh: true); // 교체 스크립트가 막 쓴 것
// 앱 자신이 업데이트 때 만든 이름만 지운다 - 다른 jj 이름 (앱 임시 폴더 · 도구 받기 · Chrome 엔진 · 비슷한 이름) 은 건드리지 않음
final others = ['jj_mkvmaker_updater.txt', p.join('jj_mkvmaker', 'a.wav'), 'jj_ffmpeg.download', p.join('jj_cef_1', 'x'), p.join('jj_tool_1', 'x'),
p.join('jj_mkvmaker_update', 'x'), 'jj_mkvmaker_update.txt', p.join('jj_other_update_1', 'x')];
for (final o in others) {
make(o);
}
// 폴더는 안에 파일을 만들어 바뀐 때가 지금이다 - 5분 뒤로 보고 지우는지 본다
final dir = Directory(p.join(tmp.path, 'jj_mkvmaker_update_2026.10.18_979'));
final gone = removeUpdateLeftovers(tmp.path, now: DateTime.now().add(const Duration(minutes: 5)));
expect(gone..sort(), ['jj_mkvmaker_update.log', 'jj_mkvmaker_update.ps1', 'jj_mkvmaker_update_2026.10.18_979'],
reason: '5분 뒤로 보면 막 생긴 기록도 2분이 지남');
expect(dir.existsSync(), isFalse);
for (final o in others) {
expect(File(p.join(tmp.path, o)).existsSync(), isTrue, reason: '$o 는 남김');
}
make('jj_mkvmaker_update.log', fresh: true);
expect(removeUpdateLeftovers(tmp.path), isEmpty, reason: '2분이 안 된 것은 남김 (스크립트가 끝나는 중일 수 있음)');
});
} }

View File

@ -219,6 +219,26 @@ void main() {
await tester.pumpAndSettle(); 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 { testWidgets('177 · 15 검토 F2: 미리 볼 수 없는 거르기 규칙 + --delete 면 빨간 경고 · 체크해야 [실행] 이 켜진다', (tester) async {
final src = p.join(tmp.path, 'fs'), dst = p.join(tmp.path, 'fd'); final src = p.join(tmp.path, 'fs'), dst = p.join(tmp.path, 'fd');
File(p.join(src, 'a.txt')) File(p.join(src, 'a.txt'))
@ -228,11 +248,12 @@ void main() {
..createSync(recursive: true) ..createSync(recursive: true)
..writeAsStringSync('o'); ..writeAsStringSync('o');
bool? r; bool? r;
final t = CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete --filter="H a.txt"'); // 관리자 지시 (10/20): H 는 이제 미리 볼 수 있다 - 아직 따르지 못하는 merge 규칙으로
final t = CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete --filter="merge C:/jj_rules/rules.txt"'); // 목록 파일은 전체 경로 (관리자 판단 10/20)
await host(tester, (ctx) async => r = await confirmDeletingRun(ctx, t)); await host(tester, (ctx) async => r = await confirmDeletingRun(ctx, t));
await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 200); await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 200);
expect(find.textContaining('미리 볼 수 없습니다'), findsOneWidget); expect(find.textContaining('미리 볼 수 없습니다'), findsOneWidget);
expect(find.textContaining('H a.txt'), findsWidgets); expect(find.textContaining('merge C:/jj_rules/rules.txt'), findsWidgets);
FilledButton run() => tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행')); FilledButton run() => tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행'));
expect(run().onPressed, isNull, reason: '체크 전에는 실행할 수 없음'); expect(run().onPressed, isNull, reason: '체크 전에는 실행할 수 없음');
await tester.tap(find.text('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)')); await tester.tap(find.text('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)'));
@ -364,7 +385,7 @@ void main() {
expect(find.text('rsync'), findsWidgets); expect(find.text('rsync'), findsWidgets);
// 222: 원본이 대상 안 - 복사면 "복사됩니다 · 원본 폴더 그대로", 이동이면 "옮겨집니다 · 원본 파일은 지워짐" // 222: 원본이 대상 안 - 복사면 "복사됩니다 · 원본 폴더 그대로", 이동이면 "옮겨집니다 · 원본 파일은 지워짐"
expect(find.textContaining('바로 아래에 복사됩니다'), findsOneWidget); expect(find.textContaining('바로 아래에 복사됩니다'), findsOneWidget);
await tester.tap(find.text('원본 파일 지우기 (--remove-source-files)')); await tester.tap(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')));
await tester.pump(); await tester.pump();
expect(find.textContaining('바로 아래로 옮겨집니다 (원본 파일은 지워지고'), findsOneWidget); expect(find.textContaining('바로 아래로 옮겨집니다 (원본 파일은 지워지고'), findsOneWidget);
expect(find.textContaining('바로 아래에 복사됩니다'), findsNothing); expect(find.textContaining('바로 아래에 복사됩니다'), findsNothing);

View File

@ -30,6 +30,18 @@ class _Backend implements DownloadBackend {
Future<void> shutdown() async {} Future<void> shutdown() async {}
} }
/// 이름을 바꾸지 않는 백엔드 (마그넷 메타데이터를 아직 받지 못한 상태)
class _Quiet extends _Backend {
_Quiet(super.kind);
@override
Future<void> start(DownloadTask t, void Function() changed) async {
t
..state = DownloadState.downloading
..progress = null;
changed();
}
}
void main() { void main() {
DownloadManager make() => DownloadManager( DownloadManager make() => DownloadManager(
backends: [_Backend(DownloadKind.video), _Backend(DownloadKind.torrent)], backends: [_Backend(DownloadKind.video), _Backend(DownloadKind.torrent)],
@ -178,4 +190,61 @@ void main() {
expect(find.textContaining('대기 중'), findsWidgets); expect(find.textContaining('대기 중'), findsWidgets);
expect(find.textContaining('받는 중'), findsNothing); expect(find.textContaining('받는 중'), findsNothing);
}); });
test('관리자 지시 (10/20): 마그넷 이름 (dn) - 주소 글 풀기 · + 는 빈칸 · 없거나 마그넷이 아니면 null', () {
const h = 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567';
expect(magnetName('$h&dn=Ubuntu+24.04%20Desktop&tr=udp%3A%2F%2Ft.example%3A80'), 'Ubuntu 24.04 Desktop');
expect(magnetName('$h&DN=%ED%95%9C%EA%B8%80%20%EC%9D%B4%EB%A6%84'), '한글 이름');
expect(magnetName('magnet:?dn=first&xt=urn:btih:00&dn=second'), 'first', reason: '처음 것');
expect(magnetName('$h&dn=bad%zz'), 'bad%zz', reason: '잘못 적힌 % 는 그대로');
expect(magnetName('$h&dn=a%0Ab'), 'a b', reason: '줄바꿈은 빈칸 (한 줄 제목)');
expect(magnetName('$h&dn='), isNull);
expect(magnetName('$h&dn=%20+'), isNull);
expect(magnetName(h), isNull);
expect(magnetName('https://example.com/?dn=x'), isNull);
final t = DownloadTask(id: '1', kind: DownloadKind.torrent, source: '$h&dn=Big+Buck+Bunny', dir: r'D:\dl');
expect(t.title, 'Big Buck Bunny');
expect(DownloadTask(id: '2', kind: DownloadKind.torrent, source: h, dir: r'D:\dl').title, h, reason: '이름이 없으면 주소 그대로');
});
testWidgets('관리자 지시 (10/20): 마그넷 줄 - 메타데이터 전에도 dn 이름 · 멈추면 막대가 움직이지 않고 주황 "멈춤"', (tester) async {
tester.view.physicalSize = const Size(1400, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final d = DownloadManager(
backends: [_Quiet(DownloadKind.video), _Quiet(DownloadKind.torrent)],
settings: () => AppSettings()..downloadRoot = r'D:\dl',
readClipboard: () async => null,
);
await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d)));
const url = 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Big+Buck+Bunny&tr=udp%3A%2F%2Ft.example%3A80';
d.add(url);
await tester.pump();
expect(d.tasks.single.state, DownloadState.downloading);
expect(find.text('Big Buck Bunny'), findsOneWidget, reason: '마그넷 주소 전체가 아니라 이름');
expect(find.textContaining('urn:btih'), findsNothing);
// 메타데이터를 받는 중: 움직이는 "준비 중…"
LinearProgressIndicator bar() => tester.widget(find.descendant(of: find.byType(DownloadProgressBar), matching: find.byType(LinearProgressIndicator)));
expect(find.text('준비 중…'), findsOneWidget);
expect(bar().value, isNull);
// 정한 시간 동안 트래커 · 피어가 없음 → 멈춤
d.tasks.single.extra
..['stalled'] = true
..['stallSince'] = DateTime.now().millisecondsSinceEpoch - 61000
..['stallReasons'] = ['firewall'];
d.selectNone();
await tester.pump();
expect(find.textContaining('트래커 · 피어에 닿지 않습니다'), findsOneWidget);
expect(find.text('멈춤 (시작 안 됨)'), findsOneWidget);
expect(find.text('준비 중…'), findsNothing);
expect(bar().value, 0, reason: '움직이지 않는 막대');
expect(bar().color, Colors.orangeAccent.withValues(alpha: 0.85));
await tester.pumpAndSettle(); // 움직이는 막대가 있으면 끝나지 않는다
// 다시 받기 시작하면 (멈춤 풀림) 원래대로
d.tasks.single.extra.remove('stalled');
d.selectNone();
await tester.pump();
expect(find.text('준비 중…'), findsOneWidget);
expect(bar().value, isNull);
});
} }

View File

@ -94,6 +94,47 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('28 확인 (10/20): 좁은 Rsync 화면의 [오른쪽] 탭 · 넓게 바꾼 두 창도 그 창의 지금 폴더가 보인다 (저장 장치 맨 위에 머물지 않음)', (tester) async {
final tmp = Directory.systemTemp.createTempSync('jj_rsync_reveal_');
addTearDown(() => tmp.deleteSync(recursive: true));
// 맨 위에 폴더가 많고 (C: 처럼) 두 창의 지금 폴더는 깊은 곳 - 펼쳐도 스크롤하지 않으면 화면 밖
for (var i = 0; i < 80; i++) {
Directory(p.join(tmp.path, 'a${i.toString().padLeft(2, '0')}')).createSync();
}
final l = p.join(tmp.path, 'zz', 'deep', 'L'), r = p.join(tmp.path, 'zz', 'deep', 'R');
Directory(p.join(l, 'sub')).createSync(recursive: true);
Directory(p.join(r, 'rsub')).createSync(recursive: true);
ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')];
addTearDown(() => ExplorerPage.debugVolumes = null);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerOrientation = 'side'
..rsyncPaths = [l, r];
tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true))));
await settle(tester, () => find.text('sub').hitTestable().evaluate().isNotEmpty);
expect(find.text('sub').hitTestable(), findsOneWidget, reason: '왼쪽은 처음부터 지금 폴더');
// [오른쪽] 탭을 처음 보면 그 창의 지금 폴더 (R) 로 - 예전에는 맨 위 (a00 …) 에서 시작
await tester.tap(find.text('오른쪽 · R'));
await settle(tester, () => find.text('rsub').hitTestable().evaluate().isNotEmpty, rounds: 40);
expect(find.text('rsub').hitTestable(), findsOneWidget);
// 넓게 바꾸면 두 창 - 둘 다 지금 폴더가 보임
tester.view.physicalSize = const Size(1280, 800);
await settle(tester, () => find.text('sub').hitTestable().evaluate().isNotEmpty && find.text('rsub').hitTestable().evaluate().isNotEmpty,
rounds: 40);
expect(find.text('sub').hitTestable(), findsOneWidget);
expect(find.text('rsub').hitTestable(), findsOneWidget);
// 다시 좁게 → [왼쪽] 탭 (지금 보이는 창) 도 지금 폴더
tester.view.physicalSize = const Size(412, 900);
await settle(tester, () => find.text('왼쪽 · L').evaluate().isNotEmpty);
await tester.tap(find.text('왼쪽 · L'));
await settle(tester, () => find.text('sub').hitTestable().evaluate().isNotEmpty, rounds: 40);
expect(find.text('sub').hitTestable(), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('208: 폰 가로 (낮은 화면) Rsync 화면에서도 방향 실행 버튼 · 모니터링은 늘 보인다 ([더 보기] 로 가는 것은 뒤쪽)', (tester) async { testWidgets('208: 폰 가로 (낮은 화면) Rsync 화면에서도 방향 실행 버튼 · 모니터링은 늘 보인다 ([더 보기] 로 가는 것은 뒤쪽)', (tester) async {
final tmp = Directory.systemTemp.createTempSync('jj_rsync_low_'); final tmp = Directory.systemTemp.createTempSync('jj_rsync_low_');
addTearDown(() => tmp.deleteSync(recursive: true)); addTearDown(() => tmp.deleteSync(recursive: true));

View File

@ -27,6 +27,7 @@ import 'package:jj_mkvmaker/ui/path_label.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'support/fake_recycle_bin.dart'; import 'support/fake_recycle_bin.dart';
import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show recycleBinForTest; import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show recycleBinForTest;
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
/// 다른 앱으로 열기를 기록만 하는 셸 (실제로 프로그램을 띄우지 않게) /// 다른 앱으로 열기를 기록만 하는 셸 (실제로 프로그램을 띄우지 않게)
class _Shell extends NoopShell { class _Shell extends NoopShell {
@ -1164,15 +1165,16 @@ void main() {
await tester.tap(find.text('취소')); await tester.tap(find.text('취소'));
await calm(tester); await calm(tester);
expect(c.settings.copyTasks, isEmpty); expect(c.settings.copyTasks, isEmpty);
expect(find.text('원본 파일 지우기 (--remove-source-files)'), findsNothing); // 양쪽에는 없음 expect(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')), findsNothing); // 양쪽에는 없음
// → 는 한 방향 · -u 없이 · 원본 파일 지우기를 고를 수 있다 (고르면 원본 폴더 남김 / 지움) // → 는 한 방향 · -u 없이 · 원본 파일 지우기를 고를 수 있다 (고르면 원본 폴더 남김 / 지움)
await act(tester, () => tester.tap(find.text('좌 → 우'))); await act(tester, () => tester.tap(find.text('좌 → 우')));
await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 400); await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 400);
expect(find.text('$ls/ → $rs/'), findsOneWidget); expect(find.text('$ls/ → $rs/'), findsOneWidget);
expect(find.text('$rs/ → $ls/'), findsNothing); expect(find.text('$rs/ → $ls/'), findsNothing);
expect(find.textContaining('-u'), findsNothing); // 옵션 낱말 -u 만 (폴더 이름 vscode-stable-user-x64 같은 것은 아님 - 시험 임시 폴더 위가 %TEMP% 일 때)
expect(find.textContaining(RegExp(r'(^|\s)-u(\s|$)')), findsNothing);
expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsNothing); expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsNothing);
await tester.tap(find.text('원본 파일 지우기 (--remove-source-files)')); await tester.tap(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')));
await calm(tester); await calm(tester);
expect(find.textContaining('-avPog --remove-source-files'), findsOneWidget); expect(find.textContaining('-avPog --remove-source-files'), findsOneWidget);
expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsOneWidget); expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsOneWidget);
@ -1428,6 +1430,8 @@ void main() {
..explorerDropAction = 'move' ..explorerDropAction = 'move'
..copyConflict = 'overwrite'; ..copyConflict = 'overwrite';
await open(tester); await open(tester);
// 전체 시험 중 (부하) 에는 목록을 늦게 읽어 e.txt 를 못 찾았다 - 두 파일이 보일 때까지
await settle(tester, () => find.text('e.txt').evaluate().isNotEmpty && find.text('doc.txt').evaluate().isNotEmpty);
await act(tester, () => tester.tap(find.text('선택').first)); await act(tester, () => tester.tap(find.text('선택').first));
await act(tester, () => tester.tap(markOf('doc.txt'))); await act(tester, () => tester.tap(markOf('doc.txt')));
await act(tester, () => tester.tap(markOf('e.txt'))); await act(tester, () => tester.tap(markOf('e.txt')));

View File

@ -0,0 +1,19 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
/// 모든 단위 시험 파일 앞에 돈다 (flutter_test 의 flutter_test_config).
/// 관리자 지시 (10/20): 앱 임시 폴더 ([DesktopStorageService.tempDirectory] = %TEMP%\jj_mkvmaker) 를 시험이 남겼다.
/// 시험 파일들은 나란히 돌아 같은 폴더를 끝에 지우면 서로 부딪치므로, 파일마다 따로 jj_store_… 를 주고 그 파일이 끝나면 지운다
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
final root = Directory.systemTemp.createTempSync('jj_store_');
DesktopStorageService.tempRoot = root.path;
tearDownAll(() {
try {
root.deleteSync(recursive: true);
} catch (_) {}
});
await testMain();
}

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) - /// l (관리자 지시 10/17): FTP · FTPS (dart:io 로 직접). 실제 서버 시험은 tool/ftp_test_server.py (pyftpdlib) -
/// 시험용 파이썬 (sftp 시험과 같은 venv) 에 pyftpdlib 가 없으면 건너뛴다 /// 시험용 파이썬 (sftp 시험과 같은 venv) 에 pyftpdlib 가 없으면 건너뛴다
/// 시험 서버 비밀번호 - 무작위 주소에 우연히 들어가지 않게 길고 드물게 (관리자 지시 10/20, 28 이 찾음)
const ftpPw = 'ftp-Pw-7Rk2xQ9t';
void main() { void main() {
final hasPy = File(sftpTestPython).existsSync(); final hasPy = File(sftpTestPython).existsSync();
@ -76,7 +79,8 @@ void main() {
late String caFp; late String caFp;
Future<void> start(String mode, {bool chain = false}) async { 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; proc = pr;
final lines = pr.stdout.transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream(); final lines = pr.stdout.transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream();
pr.stderr.drain<void>(); 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); 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 { Future<Object?> err(Future<void> Function() f) async {
@ -247,13 +251,55 @@ void main() {
FtpRegistry.configure([srv(security: 'none')]); 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 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>[]; 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) { for (final opts in sceneOptions) {
final t0 = sw.elapsedMilliseconds;
writeScene(src, want); writeScene(src, want);
final w = await realRsyncTree(rsync, src, want, opts); final w = await realRsyncTree(rsync, src, want, opts);
final wantList = listing(want);
writeScene(src, got); 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)); 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')); expect(bad, isEmpty, reason: bad.join('\n'));
}, skip: hasPy ? false : '시험용 파이썬 없음', timeout: const Timeout(Duration(minutes: 5))); }, skip: hasPy ? false : '시험용 파이썬 없음', timeout: const Timeout(Duration(minutes: 5)));
@ -285,7 +331,10 @@ void main() {
final bridge = await RemoteStreamBridge.get(); final bridge = await RemoteStreamBridge.get();
final u1 = bridge.url('ftp://f1/영상/1화.mkv'), u2 = bridge.url('ftp://f1/영상/2화.mkv'); 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, 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 { Future<(int, String)> get(String url, [String? range]) async {
final http = HttpClient(); final http = HttpClient();
try { try {

View File

@ -8,6 +8,7 @@ import 'package:jj_mkvmaker/app/live_sync.dart';
import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/rsync_filter.dart'; import 'package:jj_mkvmaker/core/rsync_filter.dart';
import 'package:jj_mkvmaker/core/sync_preview.dart'; import 'package:jj_mkvmaker/core/sync_preview.dart';
import 'package:jj_mkvmaker/core/sync_tools.dart' show splitOptions, toCygwinPath;
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/services/platform_services.dart';
@ -15,6 +16,7 @@ import 'package:jj_mkvmaker/ui/monitor_page.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'support/rsync_scenes.dart'; import 'support/rsync_scenes.dart';
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
/// 관리자 지시 (10/20): 실시간 동기화 (로컬 rsync 쌍) 를 28 의 장면 · 옵션으로 - 결과 트리 = 실제 rsync, /// 관리자 지시 (10/20): 실시간 동기화 (로컬 rsync 쌍) 를 28 의 장면 · 옵션으로 - 결과 트리 = 실제 rsync,
/// 지울지 묻는 목록 = rsync 가 지우는 것, 맞춘 뒤 "맞출 것" 이 남지 않음 (거른 것을 기다리는 것으로 세지 않음) /// 지울지 묻는 목록 = rsync 가 지우는 것, 맞춘 뒤 "맞출 것" 이 남지 않음 (거른 것을 기다리는 것으로 세지 않음)
@ -105,9 +107,9 @@ void main() {
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 300)); await tester.pump(const Duration(milliseconds: 300));
expect(find.text('대상에서 3개를 지울까요?'), findsOneWidget); expect(find.text('대상에서 3개를 지울까요?'), findsOneWidget);
expect(find.text('거른 것 2개도 지워집니다 (--delete-excluded)'), findsOneWidget); expect(find.text(keepOptionsTogether('거른 것 2개도 지워집니다 (--delete-excluded)')), findsOneWidget);
// 순서: 그냥 지울 것 → 묶음 제목 → 거른 것 // 순서: 그냥 지울 것 → 묶음 제목 → 거른 것
final y = {for (final n in ['only.txt', '거른 것 2개도 지워집니다 (--delete-excluded)', 'skip.tmp', 'cache']) n: tester.getTopLeft(find.text(n)).dy}; final y = {for (final n in ['only.txt', '거른 것 2개도 지워집니다 (--delete-excluded)', 'skip.tmp', 'cache']) n: tester.getTopLeft(find.text(keepOptionsTogether(n))).dy};
expect(y['only.txt']! < y['거른 것 2개도 지워집니다 (--delete-excluded)']!, isTrue); expect(y['only.txt']! < y['거른 것 2개도 지워집니다 (--delete-excluded)']!, isTrue);
expect(y['거른 것 2개도 지워집니다 (--delete-excluded)']! < y['skip.tmp']!, isTrue); expect(y['거른 것 2개도 지워집니다 (--delete-excluded)']! < y['skip.tmp']!, isTrue);
expect(y['거른 것 2개도 지워집니다 (--delete-excluded)']! < y['cache']!, isTrue); expect(y['거른 것 2개도 지워집니다 (--delete-excluded)']! < y['cache']!, isTrue);
@ -193,4 +195,150 @@ void main() {
expect(LiveSyncPair.fromJson(j).confirmedOptions, '-av --exclude=*.tmp'); expect(LiveSyncPair.fromJson(j).confirmedOptions, '-av --exclude=*.tmp');
expect(LiveSyncPair.fromJson({...j, 'confirmedOptions': 3}).confirmedOptions, isNull, reason: '틀린 값은 없는 것으로'); expect(LiveSyncPair.fromJson({...j, 'confirmedOptions': 3}).confirmedOptions, isNull, reason: '틀린 값은 없는 것으로');
}); });
// P0 (관리자 3a 10/20, 28 재현): 미리 볼 수 없는 거르기 규칙이 있는 지우기 포함 쌍
group('P0: 미리 볼 수 없는 거르기 규칙 - 자동은 보내기만 · 지우기는 확인 뒤 한 번', () {
late Directory tmp;
late String src, dst;
late AppController c;
setUp(() {
tmp = Directory.systemTemp.createTempSync('jj_p0_');
src = p.join(tmp.path, 's');
dst = p.join(tmp.path, 'd');
for (final (dir, rel, s) in [
(src, 'a.txt', 's:a'),
(src, 'b.txt', 's:b'),
(src, 'c.log', 's:c'),
(dst, 'a.txt', 'd:a'),
(dst, 'old.log', 'd:old'),
(dst, 'extra.txt', 'd:extra'),
]) {
File(p.join(dir, rel))
..createSync(recursive: true)
..writeAsStringSync(s);
}
c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..rsyncSource = 'custom'
..rsyncPath = sceneRsync ?? '';
});
tearDown(() => tmp.deleteSync(recursive: true));
bool there(String rel) => File(p.join(dst, rel)).existsSync();
// 관리자 지시 (10/20): -,s · H 는 이제 미리 볼 수 있다 (rsync_phsr_test) - 28 의 재현은 아직 따르지 못하는 merge 규칙 (그 파일 안에
// "H a.txt" · "-,s a.txt") 으로 한다. 실제 rsync 는 그 규칙을 따라 a.txt 를 지우지만 앱은 merge 를 읽지 않는다
for (final (label, rule, form) in [
('merge "-,s a.txt"', '-,s a.txt', '-a --filter="merge {f}"'),
('merge "H a.txt"', 'H a.txt', '-a --filter="merge {f}"'),
('-f"merge" "H a.txt"', 'H a.txt', '-a -f"merge {f}"'),
]) {
for (final confirmed in [false, true]) {
test('28 재현 $label (${confirmed ? '확인한 쌍' : '새 쌍'}): 묻는 목록 밖의 a.txt 가 지워지지 않음', () async {
final rsync = sceneRsync;
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
final rules = File(p.join(tmp.path, 'rules.txt'))..writeAsStringSync('$rule\n');
final opts = form.replaceAll('{f}', toCygwinPath(rules.path));
// 먼저 실제 rsync 가 이 규칙 + --delete 로 받는 쪽 a.txt 를 지우는 것을 확인 (위험이 실제로 있음)
final probe = p.join(tmp.path, 'probe');
Directory(probe).createSync();
for (final f in Directory(dst).listSync()) {
File(f.path).copySync(p.join(probe, p.basename(f.path)));
}
final r = await Process.run(rsync, ['-8', '--delete', ...splitOptions(opts), '${toCygwinPath(src)}/', '${toCygwinPath(probe)}/']);
expect(r.exitCode, 0, reason: '${r.stderr}');
expect(File(p.join(probe, 'a.txt')).existsSync(), isFalse, reason: '실제 rsync 는 지움');
c.settings.rsyncOptions = opts;
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: confirmed, confirmedOptions: confirmed ? opts : null);
c.settings.liveSyncPairs = [pair];
final live = LiveSync(c);
addTearDown(live.dispose);
final k = LiveSync.keyOf(pair);
for (var i = 0; i < 2; i++) {
await live.syncNow(c.settings.liveSyncPairs.single);
expect(live.status[k]!.$2, isNot(contains('실패')));
expect([there('a.txt'), there('old.log'), there('extra.txt')], [true, true, true], reason: '자동 맞추기는 지우지 않음 ($i)');
expect(there('b.txt') && there('c.log'), isTrue, reason: '보내기는 함');
expect(live.noAutoDelete[k], isNotEmpty);
expect(live.toDelete[k], isNull, reason: '묻지도 않음 (목록이 실제와 다름)');
expect(live.pending[k]!.where((d) => d.startsWith('− ')), isEmpty, reason: '− 줄이 늘 남지 않음');
}
// [지금 지우기 (확인)] - 창에 보일 목록은 미리 본 것 (a.txt 는 없음 - 그래서 빨간 경고와 체크)
final (list, _) = await live.previewDeletes(c.settings.liveSyncPairs.single);
expect(list, unorderedEquals(['old.log', 'extra.txt']));
await live.deleteNow(c.settings.liveSyncPairs.single);
expect([there('old.log'), there('extra.txt')], [false, false]);
// 다음 자동 맞추기는 다시 보내기만
File(p.join(dst, 'extra2.txt')).writeAsStringSync('x');
await live.syncNow(c.settings.liveSyncPairs.single);
expect(there('extra2.txt'), isTrue);
expect(live.noAutoDelete[k], isNotEmpty);
});
}
}
test('관리자 지시 (10/20): P (지키기) 는 이제 따른다 - 묻는 목록에 지킨 것 없음 · 확인 뒤 남음 · 다시 묻지 않음', () async {
if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
c.settings.rsyncOptions = '-a --filter="P old.log"';
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true);
c.settings.liveSyncPairs = [pair];
final live = LiveSync(c);
addTearDown(live.dispose);
final k = LiveSync.keyOf(pair);
await live.syncNow(pair);
expect(live.noAutoDelete[k], isNull);
expect(live.toDelete[k], ['extra.txt'], reason: 'old.log 는 지킴');
await live.decideDelete(c.settings.liveSyncPairs.single, delete: true);
expect((there('old.log'), there('extra.txt')), (true, false));
for (var i = 0; i < 2; i++) {
await live.syncNow(c.settings.liveSyncPairs.single);
expect(live.toDelete[k], isNull, reason: '다시 묻지 않음 ($i)');
expect(live.pending[k], isEmpty);
}
});
test('미리 볼 수 있는 규칙만이면 예전처럼 (자동 지우기 · 묻기)', () async {
if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
c.settings.rsyncOptions = '-a --exclude=*.log';
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true);
c.settings.liveSyncPairs = [pair];
final live = LiveSync(c);
addTearDown(live.dispose);
await live.syncNow(pair);
final k = LiveSync.keyOf(pair);
expect(live.noAutoDelete[k], isNull);
expect(live.toDelete[k], ['extra.txt']);
});
testWidgets('[지금 지우기 (확인)] 창: 빨간 "미리 볼 수 없음" · 체크해야 [지우고 맞추기] · [지우지 않기] 는 없음', (tester) async {
if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
c.settings.rsyncOptions = '-a --filter="merge C:/jj_rules/rules.txt"'; // 아직 따르지 못하는 규칙 (H 는 이제 따름) · 목록 파일은 전체 경로
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true);
c.settings.liveSyncPairs = [pair];
final live = LiveSync(c);
addTearDown(live.dispose);
late BuildContext ctx;
await tester.pumpWidget(MaterialApp(home: Builder(builder: (x) {
ctx = x;
return const SizedBox();
})));
await tester.runAsync(() async {
unawaited(confirmLiveDeleteNow(ctx, live, pair));
await Future<void>.delayed(const Duration(milliseconds: 500));
});
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.textContaining('미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다'), findsOneWidget);
expect(find.text('대상에서 2개를 지울까요?'), findsOneWidget);
expect(find.text('지우지 않기 (지우기 끄기)'), findsNothing);
FilledButton go() => tester.widget(find.widgetWithText(FilledButton, '2개 지우고 맞추기'));
expect(go().onPressed, isNull, reason: '체크 전에는 꺼짐');
await tester.tap(find.byType(Checkbox));
await tester.pump();
expect(go().onPressed, isNotNull);
await tester.tap(find.text('나중에'));
await tester.pump();
expect(there('old.log') && there('extra.txt') && there('a.txt'), isTrue);
});
});
} }

View File

@ -329,6 +329,26 @@ void main() {
expect(find.text('복사 · rsync 로 이동'), findsOneWidget); 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 { testWidgets('233: 이동 카드의 저장 창은 "이 이동을" · 229 설정은 Rsync 탭에도 있다', (tester) async {
final tmp = Directory.systemTemp.createTempSync('jj_mon233_'); final tmp = Directory.systemTemp.createTempSync('jj_mon233_');
addTearDown(() => tmp.deleteSync(recursive: true)); addTearDown(() => tmp.deleteSync(recursive: true));

177
test/option_paste_test.dart Normal file
View File

@ -0,0 +1,177 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/rsync_filter.dart';
import 'package:jj_mkvmaker/core/sync_preview.dart' show optionsDelete;
import 'package:jj_mkvmaker/core/sync_tools.dart';
import 'package:jj_mkvmaker/ui/sync_preview_view.dart';
import 'package:path/path.dart' as p;
import 'support/rsync_scenes.dart';
/// 관리자 지시 (10/20, 28 확인): 웹 · 문서에서 옵션 칸에 붙여 넣은 글 (굽은 따옴표 · 대시 · 보이지 않는 글자) 을
/// 곧은 글자로 쓴 것과 똑같이 읽는다. 예전에는 --exclude=‘*.log’ 가 아무것도 거르지 못해 받는 쪽 keep.log 가 지워졌고,
/// –delete 는 원본 경로로 넘어가 --delete 가 몰래 빠졌다. 보이지 않는 글자는 \u 표기로 쓴다
void main() {
// 28 의 13가지: (이름, 붙여 넣은 글, 곧은 글자로 쓴 것)
const cases = <(String, String, String)>[
('줄바꿈 없는 빈칸 (U+00A0)', '-a\u00A0--delete\u00A0--exclude=*.log', '-a --delete --exclude=*.log'),
('화면 글의 U+2060', '-a --delete --exclude=\u2060*.log', '-a --delete --exclude=*.log'),
('U+200B', '-a --delete --excl\u200Bude=*.log', '-a --delete --exclude=*.log'),
('줄바꿈 · 탭', '-a\t--delete\n--exclude=*.log\r\n', '-a --delete --exclude=*.log'),
('전각 빈칸 (U+3000)', '-a\u3000--delete\u3000--exclude=*.log', '-a --delete --exclude=*.log'),
('en dash (\u2013delete)', '-a \u2013delete --exclude=*.log', '-a --delete --exclude=*.log'),
('em dash (\u2014delete)', '-a \u2014delete --exclude=*.log', '-a --delete --exclude=*.log'),
('굽은 큰따옴표', '-a --delete --filter=\u201C- *.log\u201D', '-a --delete --filter="- *.log"'),
('굽은 작은따옴표', '-a --delete --exclude=\u2018*.log\u2019', "-a --delete --exclude='*.log'"),
('소프트 하이픈 (U+00AD)', '-a --del\u00ADete --exclude=*.log', '-a --delete --exclude=*.log'),
('줄바꿈 없는 하이픈 (U+2011)', '-a \u2011\u2011delete --exclude=*.log', '-a --delete --exclude=*.log'),
('전각 하이픈 (U+FF0D)', '-a \uFF0D\uFF0Ddelete --exclude=*.log', '-a --delete --exclude=*.log'),
('BOM 앞', '\uFEFF-a --delete --exclude=*.log', '-a --delete --exclude=*.log'),
// 15 확인 (10/20): F2 짧은 옵션 묶음 앞의 대시 하나 · F3 거르기 규칙 머리의 대시 · F5 닫는 따옴표 뒤에 글
('F2 \u2013avz', '\u2013avz --delete --exclude=*.log', '-avz --delete --exclude=*.log'),
('F2 \u2013rlpt', '\u2013rlpt --delete --exclude=*.log', '-rlpt --delete --exclude=*.log'),
('F3 --filter=\u201C\u2013 *.log\u201D', '-a --delete --filter=\u201C\u2013 *.log\u201D', '-a --delete --filter="- *.log"'),
('F3 -f\u201C\u2013 *.log\u201D', '-a --delete -f\u201C\u2013 *.log\u201D', '-a --delete -f"- *.log"'),
('F3 --filter \u201C\u2013 *.log\u201D (값 따로)', '-a --delete --filter \u201C\u2013 *.log\u201D', '-a --delete --filter "- *.log"'),
('F5 \u201C*\u201D.log', '-a --delete --exclude=\u201C*\u201D.log', '-a --delete --exclude="*".log'),
];
group('붙여 넣은 옵션 = 곧은 글자로 쓴 것', () {
for (final (name, pasted, straight) in cases) {
test(name, () {
// 옵션 칸 (OptionTextFormatter) 을 거친 글도, 거치지 않은 글 (예전에 저장된 값) 도
final typed = const OptionTextFormatter()
.formatEditUpdate(TextEditingValue.empty, TextEditingValue(text: pasted, selection: TextSelection.collapsed(offset: pasted.length)))
.text;
for (final s in [pasted, typed]) {
expect(splitOptions(s), splitOptions(straight));
expect(unreadableOptions(s), isEmpty);
expect(optionsDelete(s), optionsDelete(straight));
final f = RsyncFilter.parse(s), g = RsyncFilter.parse(straight);
expect(f.excluded('keep.log', isDir: false), g.excluded('keep.log', isDir: false));
expect(f.excluded('a.txt', isDir: false), isFalse);
}
});
}
});
test('낱말 가운데 · 이름 안은 그대로 (파일 이름일 수 있음)', () {
// 이름 안의 굽은 작은따옴표 (it’s) · 대시 (a–b) 는 따옴표 · 옵션으로 읽지 않는다
expect(splitOptions('--exclude=it\u2019s.txt'), ['--exclude=it\u2019s.txt']);
expect(splitOptions('--exclude=a\u2013b.txt'), ['--exclude=a\u2013b.txt']);
// 굽은 따옴표 안의 굽은 따옴표 (뒤가 빈칸 · 끝이 아니면 닫지 않음)
expect(splitOptions('--exclude=\u2018it\u2019s a.txt\u2019 -a'), ['--exclude=it\u2019s a.txt', '-a']);
// 짧은 옵션 뒤 (-f“- *.log”)
expect(splitOptions('-f\u201C- *.log\u201D'), ['-f- *.log']);
// 대시 하나 + 짧은 글자는 - (–av → -av), 소문자 낱말이면 -- (–delete → --delete, –bwlimit=100 → --bwlimit=100)
expect(splitOptions('\u2013av \u2013delete \u2013bwlimit=100'), ['-av', '--delete', '--bwlimit=100']);
});
test('15 확인 F1 (데이터): 닫히지 않은 따옴표는 뒤의 옵션을 삼키지 않고, 알려서 실행을 막는다 (굽은 · 곧은 모두)', () {
for (final q in ['\u2018', '\u201C', '"', "'"]) {
final o = '-a --delete --exclude=${q}a.tmp --exclude=keep.txt';
expect(splitOptions(o), ['-a', '--delete', '--exclude=${q}a.tmp', '--exclude=keep.txt'], reason: q);
expect(unreadableOptions(o).single, contains('따옴표가 닫히지 않았습니다'), reason: q);
expect(RsyncFilter.parse(o).excluded('keep.txt', isDir: false), isTrue, reason: '뒤의 규칙이 살아 있음');
}
// 닫힌 것은 문제 없음
expect(unreadableOptions('-a --exclude="a b.txt" --exclude=\u2018c d\u2019'), isEmpty);
});
test('15 확인 F4 · F5: 값 자리의 대시는 그대로, 닫는 굽은 따옴표 뒤에 글이 이어져도 닫는다', () {
expect(splitOptions('--exclude \u2013x.txt -a'), ['--exclude', '\u2013x.txt', '-a']);
expect(splitOptions('-e \u2013x'), ['-e', '\u2013x']);
expect(splitOptions('--exclude=\u201Ca b\u201D.txt'), ['--exclude=a b.txt']);
expect(splitOptions('--exclude=\u2018a b\u2019.txt'), ['--exclude=a b.txt']);
// 긴 옵션 이름 (–no-perms · –dry-run) 은 --, 목록에 없는 것은 짧은 옵션 묶음
expect(splitOptions('\u2013no-perms \u2013dry-run \u2013avh'), ['--no-perms', '--dry-run', '-avh']);
});
test('고쳐 읽어도 알아볼 수 없는 옵션 (ASCII 밖 글자로 시작) 은 알린다 - 옵션의 값 자리는 세지 않음', () {
expect(unreadableOptions('-a \u00ABdelete\u00BB'), ['경로로 읽힙니다: \u00ABdelete\u00BB']);
expect(unreadableOptions('-a \u2026delete'), ['경로로 읽힙니다: \u2026delete']);
expect(unreadableOptions('--exclude 한글.txt -a'), isEmpty, reason: '--exclude 의 값');
expect(unreadableOptions('-f 한글.txt'), isEmpty, reason: '-f 의 값');
expect(unreadableOptions('-a 한글'), ['경로로 읽힙니다: 한글'], reason: '옵션 자리 - rsync 는 원본 경로로 읽는다');
expect(unreadableOptions('-avPog --delete'), isEmpty);
// 관리자 판단 (10/20, 28 확인): - 로 시작하지 않는 낱말 · 하이픈 셋 이상 · 끝에 값 없는 옵션
expect(unreadableOptions('-a delete'), ['경로로 읽힙니다: delete']);
expect(unreadableOptions('-a - --delete'), ['경로로 읽힙니다: -']);
expect(unreadableOptions('-a ---delete'), ['---delete']);
expect(unreadableOptions('-a --exclude'), ['--exclude 의 값이 없습니다']);
expect(unreadableOptions('-a -f'), ['-f 의 값이 없습니다']);
expect(unreadableOptions('-a --exclude *.log --delete'), isEmpty, reason: '값이 있음');
expect(unreadableOptions('-a --exclude=*.log'), isEmpty);
expect(unreadableOptions(''), isEmpty);
// 28 확인 (10/20): rsync 의 짧은 별칭 (--cc · --zc · --zl) 도 값을 받는다 (오탐이던 것)
expect(unreadableOptions('-a --cc md5 --zc zlib --zl 3 --zt 2'), isEmpty);
// 곧은 하이픈과 대시가 섞인 것 (-\u2013delete)
expect(splitOptions('-a -\u2013delete \u2013-del'), ['-a', '--delete', '--del']);
expect(unreadableOptions('-a -\u2013delete'), isEmpty);
});
test('28 지적 (10/20): 안내 끝 문구는 문제 종류에 맞게', () {
String hint(String o) => optionProblemHint(optionProblems(o));
expect(hint('-a --exclude="a.tmp'), '따옴표를 닫아 주세요');
expect(hint('-a delete'), '옵션은 - 나 -- 로 시작합니다');
expect(hint('-a --exclude'), '값을 적어 주세요 (예: --exclude=*.tmp)');
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 - 옵션은 - 나 -- 로 시작합니다');
});
testWidgets('알아볼 수 없는 옵션 알림 (빨간 글, 옵션 글은 줄바꿈 없이)', (tester) async {
await tester.pumpWidget(MaterialApp(home: Scaffold(body: OptionProblemNote(optionProblems('-a \u00ABdelete\u00BB')))));
expect(find.textContaining('옵션을 알아볼 수 없습니다'), findsOneWidget);
expect(find.textContaining('곧은 따옴표와 하이픈'), findsOneWidget);
});
// 28 확인 (10/20): 막기 전 실제 rsync 는 이런 낱말을 원본 경로로 먹는다 - 그래서 [unreadableOptions] 로 막는다
test('실제 rsync: -a delete 의 delete · 끝의 --exclude 는 원본 경로로 먹혀 의도와 다르게 돈다', () async {
final rsync = sceneRsync;
if (rsync == null) return markTestSkipped('앱에 든 rsync 없음 (Windows · third_party)');
final tmp = Directory.systemTemp.createTempSync('jj_optpath_');
addTearDown(() => tmp.deleteSync(recursive: true));
final s = p.join(tmp.path, 's'), d = p.join(tmp.path, 'd');
Future<ProcessResult> run(String opts) {
writeScene(s, d, srcFiles: const ['a.txt'], dstFiles: const ['old.txt']);
return Process.run(rsync, [...splitOptions(opts), '${toCygwinPath(s)}/', '${toCygwinPath(d)}/'], workingDirectory: tmp.path);
}
// "delete" 를 원본으로 찾다 실패 (exit 23) - --delete 를 뜻했지만 old.txt 는 그대로
final r1 = await run('-r delete');
expect(r1.exitCode, isNot(0));
expect(File(p.join(d, 'old.txt')).existsSync(), isTrue);
// --exclude 가 원본 폴더를 값으로 먹고 받는 쪽 하나만 남아 목록만 보이고 끝 (아무것도 보내지 않음)
final r2 = await run('-r --exclude');
expect(r2.exitCode, 0);
expect(File(p.join(d, 'a.txt')).existsSync(), isFalse, reason: '보내지 않음');
expect(unreadableOptions('-r delete'), isNotEmpty);
expect(unreadableOptions('-r --exclude'), isNotEmpty);
});
// 실제 rsync (Windows, 앱에 든 것): 붙여 넣은 글로 맞춘 결과 = 곧은 글자로 맞춘 결과 (28 의 장면: 받는 쪽 keep.log · old.txt)
group('실제 rsync 결과가 같다', () {
for (final (name, pasted, straight) in cases) {
test(name, () async {
final rsync = sceneRsync;
if (rsync == null) return markTestSkipped('앱에 든 rsync 없음 (Windows · third_party)');
final tmp = Directory.systemTemp.createTempSync('jj_optpaste_');
addTearDown(() => tmp.deleteSync(recursive: true));
Future<Map<String, String>> run(String opts, String tag) async {
final s = p.join(tmp.path, '${tag}s'), d = p.join(tmp.path, '${tag}d');
writeScene(s, d, srcFiles: const ['a.txt', 'b.log'], dstFiles: const ['old.txt', 'keep.log']);
return realRsyncTree(rsync, s, d, opts);
}
final want = await run(straight, 'w');
expect(want.containsKey('keep.log'), isTrue, reason: '곧은 글자: *.log 는 거르므로 받는 쪽 keep.log 를 지키고');
expect(want.containsKey('old.txt'), isFalse, reason: '--delete 로 old.txt 는 지움');
expect(await run(pasted, 'g'), want);
});
}
});
}

333
test/remote_idle_test.dart Normal file
View File

@ -0,0 +1,333 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/ftp.dart';
import 'package:jj_mkvmaker/core/remote_idle.dart';
import 'package:jj_mkvmaker/core/sftp.dart';
import 'package:jj_mkvmaker/core/vfs.dart';
import 'package:jj_mkvmaker/core/webdav.dart';
import 'package:path/path.dart' as p;
import 'support/sftp_server.dart';
/// 관리자 판단 (10/20): 원격 서버의 받기 · 올리기 자료 연결에서 조각 사이에 정한 시간 동안 아무것도 오가지 않으면 실패
/// ("서버가 답하지 않습니다 (n초)"). 예전에는 서버가 멈추면 복사 · 동기화가 영원히 멈춰 있었다.
/// 멈추는 시험 서버 (보내다 멈춤 · 받다 멈춤) 로 정한 시간 뒤 실패하는지 본다 (시험은 1초로).
/// 보내기가 막히도록 큰 자료를 필요한 만큼만 만든다 (서버가 받지 않으면 운영체제 버퍼가 차서 더 가져가지 않음)
Stream<List<int>> _big() async* {
final chunk = List<int>.filled(64 * 1024, 7);
for (var i = 0; i < 4096; i++) {
yield chunk; // 최대 256MB
}
}
/// 걸린 시간 · 결과 오류
Future<(Duration, Object?)> _timed(Future<void> Function() f) async {
final sw = Stopwatch()..start();
Object? err;
try {
await f();
} catch (e) {
err = e;
}
return (sw.elapsed, err);
}
/// FTP 명령만 하는 작은 시험 서버 - RETR 은 조금 보내고 멈추고, STOR 은 자료 연결을 읽지 않는다 (받다 멈춤)
class _StallFtp {
late ServerSocket _ctl;
final _open = <Socket>[];
final _servers = <ServerSocket>[];
Socket? _data;
int get port => _ctl.port;
Future<void> start() async {
_ctl = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
_ctl.listen((s) {
_open.add(s);
void say(String l) => s.write('$l\r\n');
say('220 stall');
utf8.decoder.bind(s).transform(const LineSplitter()).listen((line) async {
switch (line.split(' ').first.toUpperCase()) {
case 'USER':
say('331 password');
case 'PASS':
say('230 ok');
case 'FEAT':
say('211 End');
case 'PWD':
say('257 "/" is current');
case 'EPSV':
final d = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
_servers.add(d);
d.listen((x) {
_open.add(x);
_data = x; // 읽지 않는다 (STOR 은 받다 멈춤)
});
say('229 Entering Extended Passive Mode (|||${d.port}|)');
case 'RETR':
say('150 sending');
for (var i = 0; i < 50 && _data == null; i++) {
await Future<void>.delayed(const Duration(milliseconds: 20));
}
_data?.add(List<int>.filled(1000, 65)); // 조금 보내고 멈춤 (닫지 않음)
case 'STOR':
say('150 receiving');
case 'QUIT':
say('221 bye');
default:
say('200 ok');
}
}, onError: (_) {});
});
}
Future<void> stop() async {
for (final s in _open) {
s.destroy();
}
for (final s in _servers) {
await s.close();
}
await _ctl.close();
}
}
/// SFTP 시험 서버 앞의 중계: [stall] 이면 양쪽으로 아무것도 넘기지 않는다 (서버가 멈춘 것처럼)
class _StallProxy {
final int upstream;
_StallProxy(this.upstream);
late ServerSocket _ss;
final _open = <Socket>[];
bool stall = false;
Future<void> start() async {
_ss = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
_ss.listen((c) async {
final u = await Socket.connect(InternetAddress.loopbackIPv4, upstream);
_open.addAll([c, u]);
u.listen((d) {
if (!stall) c.add(d);
}, onError: (_) {}, onDone: c.destroy);
c.listen((d) {
if (!stall) u.add(d);
}, onError: (_) {}, onDone: u.destroy);
});
}
Future<Socket> connect(String host, int port, Duration timeout) => Socket.connect(InternetAddress.loopbackIPv4, _ss.port, timeout: timeout);
Future<void> stop() async {
for (final s in _open) {
s.destroy();
}
await _ss.close();
}
}
void main() {
setUp(() => RemoteIdle.limit = const Duration(seconds: 1));
tearDown(() => RemoteIdle.limit = const Duration(seconds: RemoteIdle.defaultSeconds));
test('설정 "원격 서버 응답 기다리기": 처음 60초 · 30 · 60 · 120 · 300 만 · 그 밖의 값은 60 · 오류 글은 초까지', () {
expect(AppSettings().remoteIdleSeconds, 60);
for (final n in RemoteIdle.choices) {
expect(AppSettings.fromJson({'remoteIdleSeconds': n}).remoteIdleSeconds, n);
}
for (final v in [0, 45, -1, 'x', null, 99999]) {
expect(AppSettings.fromJson({'remoteIdleSeconds': v}).remoteIdleSeconds, 60, reason: '$v');
}
expect((AppSettings()..remoteIdleSeconds = 120).toJson()['remoteIdleSeconds'], 120);
expect('${const RemoteIdleTimeout(60)}', '서버가 답하지 않습니다 (60초)');
});
test('도우미: 받기 - 조각 사이가 넘으면 실패 · 끊기 · 조각이 계속 오면 실패하지 않음', () async {
final c = StreamController<List<int>>();
var cut = false;
c.add([1]);
final (t, e) = await _timed(() => idleRead(c.stream, onTimeout: () => cut = true).drain<void>());
expect(e, isA<RemoteIdleTimeout>());
expect(t, lessThan(const Duration(seconds: 5)));
expect(cut, isTrue);
await c.close();
// 0.1초마다 조각 (정한 3초 안 - 전체 시험의 부하에서도 넉넉하게, 0b 지적 10/20: 0.4초 대 1초는 한 번 넘음) - 끝까지
Stream<List<int>> slow() async* {
for (var i = 0; i < 5; i++) {
await Future<void>.delayed(const Duration(milliseconds: 100));
yield [i];
}
}
expect(await idleRead(slow(), limit: const Duration(seconds: 3)).length, 5);
});
test('도우미: 올리기 - 보내는 쪽이 더 가져가지 않으면 · 다 가져간 뒤 답이 없으면 실패', () async {
var cut = false;
final (t, e) = await _timed(() => idleWrite(_big(), (s) async {
await s.first; // 한 조각만 가져가고 멈춤
await Completer<void>().future;
}, onTimeout: () => cut = true));
expect(e, isA<RemoteIdleTimeout>());
expect(t, lessThan(const Duration(seconds: 5)));
expect(cut, isTrue);
final (_, e2) = await _timed(() => idleWrite(Stream.value([1]), (s) async {
await s.drain<void>();
await Completer<void>().future; // 마지막 답이 없음
}));
expect(e2, isA<RemoteIdleTimeout>());
expect(await idleWrite(Stream.value([1, 2]), (s) => s.expand((x) => x).toList()), [1, 2], reason: '멈추지 않으면 그대로');
});
group('FTP (멈추는 시험 서버)', () {
late _StallFtp server;
setUp(() async {
server = _StallFtp();
await server.start();
FtpRegistry.configure([FtpServer(id: 'st', name: 'stall', host: '127.0.0.1', port: server.port, user: 'jj', password: 'pw', security: 'none')]);
});
tearDown(() async {
FtpRegistry.configure(const []);
await server.stop();
});
test('받다가 서버가 보내지 않으면 (보내다 멈춤) 정한 시간 뒤 "서버가 답하지 않습니다" · 로컬에 받다 만 파일 없음', () async {
final tmp = Directory.systemTemp.createTempSync('jj_idleftp_');
addTearDown(() => tmp.deleteSync(recursive: true));
final local = p.join(tmp.path, 'a.bin');
final (t, e) = await _timed(() async => vWrite(local, await vOpenRead('ftp://st/a.bin')));
expect(e, isA<RemoteIdleTimeout>(), reason: '$e');
expect('$e', '서버가 답하지 않습니다 (1초)');
expect(t, lessThan(const Duration(seconds: 10)));
expect(tmp.listSync(), isEmpty, reason: '받다 만 .jjsync 를 남기지 않음');
});
test('올리다가 서버가 받지 않으면 (받다 멈춤) 정한 시간 뒤 실패', () async {
final (t, e) = await _timed(() => FtpRegistry.client('st').write('/b.bin', _big()));
expect(e, isA<RemoteIdleTimeout>(), reason: '$e');
expect(t, lessThan(const Duration(seconds: 15)));
});
});
group('WebDAV (멈추는 시험 서버)', () {
late HttpServer http;
final seen = <String>[];
setUp(() async {
seen.clear();
http = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
http.listen((req) async {
seen.add('${req.method} ${req.uri.path}');
final res = req.response;
switch ((req.method, req.uri.path)) {
case ('GET', '/dav/a.bin'):
res.contentLength = 1 << 20;
res.add(List<int>.filled(1000, 65)); // 조금 보내고 멈춤
await res.flush();
case ('GET', '/dav/nohead.bin'):
break; // 답 (머리) 을 보내지 않음
case ('PUT', _):
break; // 몸을 읽지 않고 답도 없음 (받다 멈춤)
case ('PROPFIND', _):
res.statusCode = 404; // 지우기 전 확인 (stat) - 없음으로
await res.close();
default:
res.statusCode = 204;
await res.close();
}
});
DavRegistry.configure([DavServer(id: 'st', name: 'stall', url: 'http://127.0.0.1:${http.port}/dav')]);
});
tearDown(() async {
DavRegistry.configure(const []);
await http.close(force: true);
});
test('받다가 멈춤 · 답 머리가 없음 → 정한 시간 뒤 실패', () async {
final (t, e) = await _timed(() async => (await vOpenRead('dav://st/a.bin')).drain<void>());
expect(e, isA<RemoteIdleTimeout>(), reason: '$e');
expect(t, lessThan(const Duration(seconds: 10)));
final (t2, e2) = await _timed(() async => (await vOpenRead('dav://st/nohead.bin')).drain<void>());
expect(e2, isA<RemoteIdleTimeout>(), reason: '$e2');
expect(t2, lessThan(const Duration(seconds: 10)));
});
test('올리다가 서버가 받지 않으면 실패 · 받다 만 .jjpart 지우기를 서버에 부탁 (그것도 정한 시간까지만)', () async {
final (t, e) = await _timed(() => vWrite('dav://st/b.bin', _big()));
expect(e, isA<RemoteIdleTimeout>(), reason: '$e');
expect(t, lessThan(const Duration(seconds: 20)));
expect(seen, contains('DELETE /dav/b.bin.jjpart'));
});
});
group('SFTP (paramiko 시험 서버 + 멈추는 중계)', () {
final has = sftpTestServerAvailable;
late Directory tmp;
SftpTestServer? server;
_StallProxy? proxy;
setUp(() async {
if (!has) return;
tmp = Directory.systemTemp.createTempSync('jj_idlesftp_');
final root = Directory(p.join(tmp.path, 'root'))..createSync();
File(p.join(root.path, 'a.bin')).writeAsBytesSync(List<int>.filled(4 << 20, 1));
server = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'hostkey'));
proxy = _StallProxy(server!.port);
await proxy!.start();
});
tearDown(() async {
if (!has) return;
await proxy?.stop();
await server?.stop();
for (var i = 0;; i++) {
try {
if (tmp.existsSync()) tmp.deleteSync(recursive: true);
break;
} on FileSystemException {
if (i > 30) rethrow;
await Future<void>.delayed(const Duration(milliseconds: 100));
}
}
});
SftpRemote client() => SftpRemote(
SftpServer(id: 's1', name: 'test', host: '127.0.0.1', port: server!.port, user: 'jj', password: 'pw', hostKeyFingerprint: server!.fingerprint),
connect: proxy!.connect);
test('받는 중에 서버가 멈추면 · 명령 (열기) 에 답이 없으면 정한 시간 뒤 실패', () async {
final c = client();
addTearDown(c.close);
// 준비 (연결 · 목록 · 열기) 는 넉넉하게 - 함께 도는 시험의 부하에서 paramiko 의 답이 1초를 넘은 적이 있다 (15 검증 10/20)
RemoteIdle.limit = const Duration(seconds: 10);
expect((await c.list('/')).map((e) => e.rel), contains('/a.bin'));
final s = await c.openReadFrom('a.bin'); // 열기까지는 됨
RemoteIdle.limit = const Duration(seconds: 1); // 받기 (listen) 때 이 시간으로
proxy!.stall = true; // 이제 서버가 아무것도 보내지 않음
final (t, e) = await _timed(() => s.drain<void>());
expect(e, isA<RemoteIdleTimeout>(), reason: '$e');
expect(t, lessThan(const Duration(seconds: 10)));
final c2 = client();
addTearDown(c2.close);
proxy!.stall = false;
RemoteIdle.limit = const Duration(seconds: 10);
await c2.list('/');
RemoteIdle.limit = const Duration(seconds: 1);
proxy!.stall = true;
final (t2, e2) = await _timed(() => c2.openReadFrom('a.bin'));
expect(e2, isA<RemoteIdleTimeout>(), reason: '$e2');
expect(t2, lessThan(const Duration(seconds: 10)));
}, skip: has ? false : 'SFTP 시험 서버 (paramiko) 없음');
test('올리는 중에 서버가 멈추면 정한 시간 뒤 실패', () async {
final c = client();
addTearDown(c.close);
RemoteIdle.limit = const Duration(seconds: 10); // 준비는 넉넉하게 (위와 같음)
await c.list('/');
RemoteIdle.limit = const Duration(seconds: 1);
proxy!.stall = true;
final (t, e) = await _timed(() => c.write('b.bin', _big()));
expect(e, isA<RemoteIdleTimeout>(), reason: '$e');
expect(t, lessThan(const Duration(seconds: 10)));
}, skip: has ? false : 'SFTP 시험 서버 (paramiko) 없음');
});
}

View File

@ -58,6 +58,15 @@ void main() {
expect(ex('-f"+ *.txt" --exclude=*', 'old.log'), isTrue); expect(ex('-f"+ *.txt" --exclude=*', 'old.log'), isTrue);
expect(ex('-f"- *.log"', 'c.log'), isTrue); expect(ex('-f"- *.log"', 'c.log'), isTrue);
expect(RsyncFilter.parse('--files-from=list.txt').unsupported, ['--files-from=list.txt']); expect(RsyncFilter.parse('--files-from=list.txt').unsupported, ['--files-from=list.txt']);
// 28 확인 (10/20): 짧은 옵션 묶음 안의 f (rsync 처럼 그 뒤가 값, 비면 다음 낱말)
expect(ex('-avf"- *.log"', 'a.log'), isTrue);
expect(ex('-rf "- x"', 'x'), isTrue);
expect(ex('-avPf"+ *.txt" -f"- *"', 'a.txt'), isFalse);
expect(ex('-avPf"+ *.txt" -f"- *"', 'a.log'), isTrue);
expect(RsyncFilter.parse('-f"- *.CFG"').unsupported, isEmpty, reason: '패턴의 C · F 는 -C · -F 가 아님');
expect(RsyncFilter.parse('-avC').unsupported, ['-C']);
expect(RsyncFilter.parse('-aFF').unsupported, ['-F', '-F']);
expect(RsyncFilter.parse('-e "ssh -p 22" -av').isEmpty, isTrue, reason: '-e 의 값은 거르기가 아님');
}); });
test('--exclude-from 파일 (빈 줄 · # · + / - 머리) · 못 읽으면 알림 목록', () { test('--exclude-from 파일 (빈 줄 · # · + / - 머리) · 못 읽으면 알림 목록', () {
final f = RsyncFilter.parse('--exclude-from=list.txt', final f = RsyncFilter.parse('--exclude-from=list.txt',
@ -76,9 +85,11 @@ void main() {
expect(ex('--exclude=**/deep/**', 'a/deep/x'), isTrue); expect(ex('--exclude=**/deep/**', 'a/deep/x'), isTrue);
expect(ex('--exclude=**/deep/**', 'deep', dir: true), isFalse); expect(ex('--exclude=**/deep/**', 'deep', dir: true), isFalse);
}); });
test('15 검토 F1 · F3: 고치개 (,s · ,r · ,! · ,p) · H · S · P · R · merge 와 exclude-from 의 ! · 머리 줄은 적용하지 않고 "미리 볼 수 없음"', () { test('관리자 지시 (10/20): 고치개 ,s · ,r 과 H · S · P · R 은 따른다 (rsync_phsr_test 가 실제 rsync 와 견줌) · 15 검토 F1 · F3: 그 밖의 고치개 (,! · ,p) · merge 와 exclude-from 의 ! · 머리 줄은 적용하지 않고 "미리 볼 수 없음"', () {
for (final o in ['--filter="-,s a.txt"', '--filter="+,r a.txt"', '--filter="-,! a.txt"', '--filter="-,p a.txt"', '--filter="H a.txt"', for (final o in ['--filter="-,s a.txt"', '--filter="+,r a.txt"', '--filter="H a.txt"', '--filter="P a.txt"', '--filter="S a.txt"', '--filter="R a.txt"']) {
'--filter="P a.txt"', '--filter="S a.txt"', '--filter="R a.txt"', '--filter=". rules"', '--filter=": .rsync-filter"']) { expect(RsyncFilter.parse(o).unsupported, isEmpty, reason: o);
}
for (final o in ['--filter="-,! a.txt"', '--filter="-,p a.txt"', '--filter=". rules"', '--filter=": .rsync-filter"']) {
final f = RsyncFilter.parse(o); final f = RsyncFilter.parse(o);
expect(f.unsupported, isNotEmpty, reason: o); expect(f.unsupported, isNotEmpty, reason: o);
expect(f.excluded('a.txt', isDir: false), isFalse, reason: '$o 는 지키지 않음 (미리 보기가 더 적게 지우게 되지 않게)'); expect(f.excluded('a.txt', isDir: false), isFalse, reason: '$o 는 지키지 않음 (미리 보기가 더 적게 지우게 되지 않게)');
@ -205,14 +216,15 @@ void main() {
} }
for (final opts in ['--filter="-,s a.txt"', '--filter="H a.txt"']) { for (final opts in ['--filter="-,s a.txt"', '--filter="H a.txt"']) {
test('15 재현: $opts + --delete - 실제 rsync 는 받는 쪽 a.txt 를 지움 → 미리 보기는 그 규칙을 "미리 볼 수 없음" 으로 알림', () async { test('15 재현: $opts + --delete - 실제 rsync 는 받는 쪽 a.txt 를 지움 → 미리 보기도 지움으로 (관리자 지시 10/20: 이제 이 규칙을 따름)', () async {
if (!Platform.isWindows || !File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음'); if (!Platform.isWindows || !File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음');
File(p.join(dst, 'a.txt')).writeAsStringSync('받는 쪽'); File(p.join(dst, 'a.txt')).writeAsStringSync('받는 쪽');
expect((await preview(opts))['a.txt'], 'delete', reason: '보내는 쪽에서만 빼므로 받는 쪽 a.txt 는 지킴이 없다');
final r = await Process.run(rsync, ['-r', '-8', '--delete', '-i', ...splitOptions(opts), '${toCygwinPath(src)}/', '${toCygwinPath(dst)}/'], final r = await Process.run(rsync, ['-r', '-8', '--delete', '-i', ...splitOptions(opts), '${toCygwinPath(src)}/', '${toCygwinPath(dst)}/'],
stdoutEncoding: utf8, stderrEncoding: utf8); stdoutEncoding: utf8, stderrEncoding: utf8);
expect(r.exitCode, 0, reason: '${r.stderr}'); expect(r.exitCode, 0, reason: '${r.stderr}');
expect(File(p.join(dst, 'a.txt')).existsSync(), isFalse, reason: '실제 rsync 는 지움 (보내는 쪽만 빼기 · 받는 쪽은 지키지 않음)'); expect(File(p.join(dst, 'a.txt')).existsSync(), isFalse, reason: '실제 rsync 는 지움 (보내는 쪽만 빼기 · 받는 쪽은 지키지 않음)');
expect(RsyncFilter.parse(opts).unsupported, isNotEmpty, reason: '미리 보기는 이 규칙을 따르지 못한다고 알려야 함'); expect(RsyncFilter.parse(opts).unsupported, isEmpty);
}); });
} }
@ -280,6 +292,10 @@ void main() {
// 28 확인 (63ef362): 붙여 쓴 -f // 28 확인 (63ef362): 붙여 쓴 -f
'-a --delete -f"+ *.txt" --exclude=*', '-a --delete -f"+ *.txt" --exclude=*',
'-a --delete -f"- *.log"', '-a --delete -f"- *.log"',
// 28 확인 (10/20): 짧은 옵션 묶음 안의 f
'-a --delete -rf"- *.log"',
'-a --delete -avf"+ *.txt" -f"- *"',
'--delete -raf "- build/"',
].indexed) { ].indexed) {
test('${i + 1}: $opts', () async { test('${i + 1}: $opts', () async {
if (!Platform.isWindows || !File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음'); if (!Platform.isWindows || !File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음');

View File

@ -0,0 +1,272 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/live_sync.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/app/transfer_job.dart';
import 'package:jj_mkvmaker/core/rsync_launch.dart';
import 'package:jj_mkvmaker/core/sync_tools.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:path/path.dart' as p;
import 'support/rsync_scenes.dart' show sceneRsync, treeOf;
/// P0 (관리자 지시 10/20, 28 재현 - 데이터): Windows 의 rsync (MSYS2 런타임) 가 명령 줄을 다시 읽어 ' 를 짝짓고 (거르기가 사라져
/// 지킬 파일이 지워짐 · ' 경로가 합쳐져 실패) * ? [ ] { } 를 작업 폴더의 파일로 펼치던 것 - 응답 파일 + MSYS=noglob 으로 그대로 넘긴다.
/// 앱이 rsync 를 띄우는 두 길 (복사 · Rsync 화면 · 모니터링 · 최종 정리 = TransferJob, 실시간 동기화 = LiveSync) 을 실제 rsync 로.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('도우미: Windows 는 응답 파일 · MSYS=noglob, 그 밖은 그대로 · 큰따옴표가 든 인수는 막음', () async {
final plain = await prepareRsyncLaunch(['-a', "it's"], windows: false);
expect(plain.args, ['-a', "it's"]);
expect(plain.environment, isNull);
final w = await prepareRsyncLaunch(['-a', "--exclude=it's.txt", 'a b'], windows: true);
addTearDown(w.dispose);
expect(w.args, ['@args.txt']);
expect(w.environment!['MSYS'], contains('noglob'));
expect(File(p.join(w.workingDirectory!, 'args.txt')).readAsStringSync(), '"-a" "--exclude=it\'s.txt" "a b"');
await expectLater(prepareRsyncLaunch(['--exclude=a"b'], windows: true), throwsA(isA<StateError>()));
// 옵션 칸에서도 미리 빨갛게 (Windows 만)
expect(unreadableOptions('-a --exclude=\'a"b\'', windows: true), hasLength(1));
expect(unreadableOptions('-a --exclude=\'a"b\'', windows: false), isEmpty);
expect(unreadableOptions('-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"', windows: true), isEmpty);
});
test('관리자 판단 (10/20): 목록 파일의 상대 경로는 막는다 (rsync 와 미리 보기가 다른 작업 폴더 기준으로 읽음) - 전체 경로 · 폴더마다 읽는 규칙은 그대로', () {
for (final o in [
'-a --exclude-from=list.txt',
'-a --exclude-from list.txt',
'-a --include-from=./a.txt',
'-a --files-from=sub/files.txt',
'-a --filter=". rules.txt"',
'-a --filter="merge,- rules.txt"',
'-a --filter ". w.txt"',
'-a -f". y.txt"',
'-avf ". z.txt"',
'-a --exclude-from=~/list.txt',
]) {
expect(unreadableOptions(o, windows: true), [contains('목록 파일은 전체 경로로 적어 주세요')], reason: o);
}
for (final o in [
r'-a --exclude-from=C:\lists\l.txt',
'-a --exclude-from=C:/lists/l.txt',
'-a --include-from=/cygdrive/c/l.txt',
r'-a --files-from=\\nas\share\l.txt',
'-a --filter=". /cygdrive/c/rules.txt"',
r'-a -f". C:\r.txt"',
'-a --filter=": .rsync-filter"',
'-a --filter="dir-merge .x"',
'-a -F',
'-a --exclude=list.txt',
// 받는 쪽 기준이라 그대로 (28 확인)
'-a --backup-dir=old --partial-dir=.part --temp-dir=tmp --compare-dest=../base',
r'-a --log-file=C:\logs\r.log --write-batch=/cygdrive/c/b/batch',
]) {
expect(unreadableOptions(o, windows: true), isEmpty, reason: o);
}
// 관리자 지시 (10/20, 28 확인): 기록 · 배치 파일도 전체 경로만 (상대면 응답 파일 임시 폴더에 생겼다가 함께 지워짐)
for (final o in ['-a --log-file=r.log', '-a --log-file r.log', '-a --write-batch=b', '-a --only-write-batch b', '-a --read-batch=./b']) {
expect(unreadableOptions(o, windows: true), [contains('기록 · 배치 파일은 전체 경로로 적어 주세요')], reason: o);
}
});
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;
setUp(() => tmp = Directory.systemTemp.createTempSync('jj_msysargs_'));
tearDown(() => tmp.deleteSync(recursive: true));
/// 원본 · 대상 폴더를 만들고 파일을 넣는다 (내용 = 쪽:이름)
(String, String) scene(String sn, String dn, List<String> sf, List<String> df) {
final s = Directory(p.join(tmp.path, sn))..createSync(recursive: true);
final d = Directory(p.join(tmp.path, dn))..createSync(recursive: true);
for (final f in sf) {
File(p.join(s.path, f))
..parent.createSync(recursive: true)
..writeAsStringSync('s:$f');
}
for (final f in df) {
File(p.join(d.path, f))
..parent.createSync(recursive: true)
..writeAsStringSync('d:$f');
}
return (s.path, d.path);
}
// (이름, 옵션, 원본 폴더, 대상 폴더, 원본 파일, 대상 파일, 남아야 할 것 (대상), 없어야 할 것 (대상))
final cases = <(String, String, String, String, List<String>, List<String>, List<String>, List<String>)>[
("28 재현: ' 두 개의 --exclude", '-a --delete --exclude="it\'s.txt" --exclude="Bob\'s.txt"', 's', 'd', ['a.txt'],
["it's.txt", "Bob's.txt", 'old.txt'], ["it's.txt", "Bob's.txt", 'a.txt'], ['old.txt']),
("' 하나의 --exclude", "-a --delete --exclude=\"it's.txt\"", 's', 'd', ['a.txt'], ["it's.txt", 'old.txt'], ["it's.txt", 'a.txt'], ['old.txt']),
("' 가 든 원본 경로", '-a --delete', "s'1", 'd', ["a'b.txt"], ['old.txt'], ["a'b.txt"], ['old.txt']),
("' 가 든 원본 · 대상 경로", '-a --delete', "s'1", "d'2", ['a.txt', "c'd/e'f.txt"], ['old.txt'], ['a.txt', "c'd/e'f.txt"], ['old.txt']),
("' 경로 + ' --exclude", "-a --delete --exclude=\"it's.txt\"", "s'1", "d'2", ['a.txt'], ["it's.txt", 'old.txt'], ["it's.txt", 'a.txt'],
['old.txt']),
('따로 쓴 * (작업 폴더에 맞는 파일이 있어도)', '-a --delete --exclude *.txt', 's', 'd', ['a.txt', 'n.log'], ['keep.txt', 'old.log'],
['keep.txt', 'n.log'], ['old.log', 'a.txt']),
('따로 쓴 ? · [ ] · { }', '-a --delete --exclude b?.txt --exclude [x]* --exclude {q,r}.txt', 's', 'd', ['a.txt'],
['b9.txt', 'xk.txt', '{q,r}.txt', 'q.txt', 'old.txt'], ['b9.txt', 'xk.txt', '{q,r}.txt', 'a.txt'], ['q.txt', 'old.txt']),
('[ ] · { } · 빈칸 · 한글이 든 경로', '-a --delete', '원본 [1] {a}', "받는 쪽 [2] {b}'s", ['가 나.txt'], ['old.txt'], ['가 나.txt'], ['old.txt']),
];
for (final (name, opts, sn, dn, sf, df, keep, gone) in cases) {
test('복사 작업 (TransferJob): $name', () async {
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
final (s, d) = scene(sn, dn, sf, df);
// 작업 폴더에 글로브에 맞을 파일을 둔다 (예전에는 따로 쓴 * 가 이것들로 펼쳐졌다)
final cwd = Directory.current;
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: '${job.error}');
final t = treeOf(d);
for (final k in keep) {
expect(t.containsKey(k), isTrue, reason: '$name: $k 가 있어야 함 - $t');
}
for (final g in gone) {
expect(t.containsKey(g), isFalse, reason: '$name: $g 는 없어야 함 - $t');
}
expect(Directory.current.path, cwd.path);
}, 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']);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..rsyncSource = 'custom'
..rsyncPath = rsync
..rsyncOptions = '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"'
..liveSyncPairs = [LiveSyncPair(s, d, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"')];
final live = LiveSync(c);
addTearDown(live.dispose);
final pair = c.settings.liveSyncPairs.single;
await live.syncNow(pair);
expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패')));
final t = treeOf(d);
expect(t.keys.toSet(), {"it's.txt", "Bob's.txt", 'a.txt'}, reason: '거르기를 지키고 old.txt 만 지움');
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
test('응답 파일은 끝나면 지운다', () async {
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
final (s, d) = scene('s', 'd', ['a.txt'], []);
final job = TransferJob(sources: [s], dest: d, move: false, method: CopyMethod.rsync, options: '-a', rsyncExe: rsync, contents: true);
await job.run();
expect(job.error, isNull);
// 이 작업의 응답 파일 (이 시험의 경로가 든 것) 이 남지 않았는지 - 함께 도는 다른 시험의 것은 세지 않는다
final mine = [
for (final e in Directory.systemTemp.listSync())
if (p.basename(e.path).startsWith('jj_rsargs_') && File(p.join(e.path, 'args.txt')).existsSync())
if (File(p.join(e.path, 'args.txt')).readAsStringSync().contains(toCygwinPath(s))) e.path,
];
expect(mine, isEmpty);
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
});
}

193
test/rsync_phsr_test.dart Normal file
View File

@ -0,0 +1,193 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/live_sync.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/file_ops.dart';
import 'package:jj_mkvmaker/core/rsync_filter.dart';
import 'package:jj_mkvmaker/core/sync_preview.dart';
import 'package:jj_mkvmaker/core/webdav.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:path/path.dart' as p;
import 'support/dav_server.dart';
import 'support/rsync_scenes.dart';
/// 관리자 지시 (10/20): H · S · P · R 과 고치개 ,s · ,r 을 rsync 와 똑같이 따른다 - 기준은 실제 rsync 를 돌린 (-n 아님) 결과 트리.
/// 장면 · 옵션마다 ① 미리 보기 (previewSync 로 예측한 트리) ② 원격 맞추기 (WebDAV 시험 서버) ③ 실시간 동기화 (로컬 rsync 쌍:
/// 묻는 목록 = rsync 가 지운 것 · 맞춘 뒤 맞출 것 없음) ④ 실시간 동기화 (WebDAV 쌍) 가 모두 실제 rsync 와 같아야 한다.
/// 하나라도 다르면 그 규칙은 미리 볼 수 없는 것 (unsupported) 으로 남겨야 한다.
/// 보내는 쪽 (양쪽에 같은 이름이 많게 - H · S 는 보내기, P · R 은 지우기에 걸리게)
const _src = [
'a.txt', 'b.log', 'keep.log', 'both.txt', 'both.log', 'shared/x.txt', 'shared/y.log', 'build/x.o', 'build/sub/y.o',
'sub/c.txt', 'sub/deep/d.txt', 'sub/deep/e.log', 'tmp/t.txt', '한글 폴더/가.txt', 'docs/r.md', 'z.txt', 'keep/k.txt',
];
/// 받는 쪽 (같은 이름은 크기가 달라 늘 바뀜 · 받는 쪽에만 있는 것은 지우기 대상)
const _dst = [
'a.txt', 'keep.log', 'both.txt', 'both.log', 'shared/x.txt', 'shared/onlyd.log', 'shared/onlyd.txt', 'build/old.o',
'old/keep.log', 'old/gone.txt', 'old2/deep/x/keep.log', 'old2/deep/x/g.txt', 'old2/e.txt', 'tmp/old.txt', 'extra.txt',
'extra.log', 'sub/deep/z.txt', '한글 폴더/나.txt', 'onlydst/a.bak',
// 28 확인 (10/20): 원본에도 있는 폴더 안의, 받는 쪽에만 있는 파일 · 하위 폴더 ("P keep/***" 가 안쪽까지 지키는지)
'keep/extra.txt', 'keep/sub/deep.txt',
];
/// 규칙 장면 (지우기 포함이 많게 - 지우기가 데이터 쪽 위험)
const phsrOptions = [
'-a --delete --filter="H *.log"',
'-a --delete --filter="-,s *.log"',
'-a --delete --filter="P *.log"',
'-a --delete --filter="-,r *.log"',
'-a --delete --filter="P old/"',
'-a --delete --filter="R *.log" --exclude=*.log',
'-a --delete --exclude=*.log --filter="R keep.log"',
'-a --delete --filter="R keep.log" --exclude=*.log',
'-a --delete --exclude=*.log --delete-excluded --filter="P keep.log"',
'-a --delete --filter="P keep.log" --exclude=*.log --delete-excluded',
'-a --delete --filter="S a.txt" --filter="H *.txt"',
'-a --delete --filter="H *.txt" --filter="S a.txt"',
'-a --delete --filter="H build/"',
'-a --delete --filter="P build/"',
'-a --delete --filter="-,sr *.log"',
'-a --delete --filter="-,sr *.log" --delete-excluded',
'-a --delete --filter="hide *.log"',
'-a --delete --filter="protect /old2/"',
'-a --delete --filter="risk *.txt" --filter="P *"',
'-a --delete --filter="P *" --filter="R *.txt"',
'-a --delete -f"H deep/" -f"P z.txt"',
'-a --delete --filter="H tmp/" --filter="P tmp/old.txt"',
'-a --delete --filter="+,r old/***" --exclude=old/',
'-a --delete --filter="H 한글*/"',
'-a --delete --filter="S *.log" --exclude=*.log',
'-a --delete --filter="+,s *.log" --exclude=*.log',
'-a --delete --filter="show shared/" --filter="hide shared/*"',
'-a --delete --filter="H *.log" --delete-excluded',
'-a --delete --filter="P shared/onlyd.*" --filter="H shared/"',
'-a --filter="H *.log"',
'-a --filter="P *.log" --exclude=*.txt',
// 28 확인 (10/20): "이름/***" 이 안쪽 (받는 쪽에만 있는 하위 폴더) 까지
'-a --delete --filter="R keep/extra.txt" --filter="P keep/***"',
'-a --delete --filter="P keep/***"',
'-a --delete --exclude=keep/***',
'-a --delete --filter="H keep/***"',
];
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final rsync = sceneRsync;
late Directory tmp, remote;
late TestDavServer server;
const id = 'nas';
DavServer dav() => DavServer(id: id, name: '시험', url: server.url, user: 'user', password: 'pass');
setUp(() async {
// 위젯 시험 바인딩은 HttpClient 를 가짜 (늘 400) 로 바꾼다 - 시험 WebDAV 서버 (127.0.0.1) 에 실제로 묻는다
HttpOverrides.global = null;
tmp = Directory.systemTemp.createTempSync('jj_phsr_');
remote = Directory(p.join(tmp.path, 'remote'))..createSync();
server = TestDavServer(remote);
await server.start();
DavRegistry.configure([dav()]);
});
tearDown(() async {
DavRegistry.configure([]);
await server.stop();
tmp.deleteSync(recursive: true);
});
test('이 규칙들은 이제 미리 볼 수 있다 (unsupported 없음) · 그 밖의 고치개 · merge 는 그대로 알림', () {
for (final o in phsrOptions) {
expect(RsyncFilter.parse(o).unsupported, isEmpty, reason: o);
}
for (final o in ['--filter="-,! a"', '--filter="-,p a"', '--filter="-,x a"', '--filter="H,r a"', '--filter=". rules"', '--filter="merge x"', '--filter="!"']) {
expect(RsyncFilter.parse(o).unsupported, isNotEmpty, reason: o);
}
// 쪽: H 는 보내기에만 (받는 쪽을 지키지 않음) · P 는 지우기에만 (보내기는 그대로)
final h = RsyncFilter.parse('--filter="H *.log"');
expect((h.excluded('a.log', isDir: false), h.protects('a.log', isDir: false)), (true, false));
final pr = RsyncFilter.parse('--filter="P *.log"');
expect((pr.excluded('a.log', isDir: false), pr.protects('a.log', isDir: false)), (false, true));
// --delete-excluded: 쪽을 적지 않은 규칙은 보내기에만 · ,sr 로 적은 것은 양쪽
final de = RsyncFilter.parse('--exclude=*.log --filter="-,sr *.bak" --delete-excluded');
expect((de.excluded('a.log', isDir: false), de.protects('a.log', isDir: false)), (true, false));
expect((de.excluded('a.bak', isDir: false), de.protects('a.bak', isDir: false)), (true, true));
});
for (final (i, opts) in phsrOptions.indexed) {
test('${i + 1}: $opts - 미리 보기 · 원격 맞추기 · 실시간 동기화 (로컬 · 원격) = 실제 rsync', () async {
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');
final src = p.join(tmp.path, 'src'), want = p.join(tmp.path, 'rsync');
writeScene(src, want, srcFiles: _src, dstFiles: _dst);
final before = treeOf(want);
final w = await realRsyncTree(rsync, src, want, opts);
final gone = [for (final k in before.keys) if (!w.containsKey(k)) k];
final delete = optionsDelete(opts);
final filter = RsyncFilter.parse(opts);
final why = <String>[];
// ① 미리 보기: 받는 쪽 트리 + 미리 보기 줄 → 예측한 트리
final pv = p.join(tmp.path, 'pv');
writeScene(src, pv, srcFiles: _src, dstFiles: _dst);
final items = await previewSync(src, pv, toRight: true, delete: delete, filter: filter);
final guess = Map.of(before);
for (final x in items) {
if (x.action == PreviewAction.delete) {
guess.removeWhere((k, _) => k == x.rel || k.startsWith('${x.rel}/'));
}
}
for (final x in items) {
if (x.action == PreviewAction.add || x.action == PreviewAction.update) {
guess[x.rel] = x.isDir ? '/' : File(p.joinAll([src, ...x.rel.split('/')])).readAsStringSync();
}
}
for (final d in treeDiff(w, guess)) {
why.add('미리 보기: $d');
}
// ② 원격 맞추기 (WebDAV)
final got = p.join(remote.path, 'm');
writeScene(src, got, srcFiles: _src, dstFiles: _dst);
await FileOps().mirror(src, 'dav://$id/m', delete: delete, filter: filter);
for (final d in treeDiff(w, treeOf(got))) {
why.add('원격 맞추기: $d');
}
// ③ · ④ 실시간 동기화 (새 쌍 - 지울 것이 있으면 묻고, 확인하면 맞춤)
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..rsyncSource = 'custom'
..rsyncPath = rsync
..rsyncOptions = opts
..webdavServers = [dav()];
for (final (label, target, local) in [('실시간 (로컬)', p.join(tmp.path, 'live'), true), ('실시간 (원격)', 'dav://$id/l', false)]) {
final dir = local ? target : p.join(remote.path, 'l');
writeScene(src, dir, srcFiles: _src, dstFiles: _dst);
final pair = LiveSyncPair(src, target, method: 'rsync', delete: delete);
c.settings.liveSyncPairs = [pair];
final live = LiveSync(c);
final k = LiveSync.keyOf(pair);
await live.syncNow(pair);
if (live.status[k]!.$2.contains('실패')) why.add('$label 상태: ${live.status[k]!.$2}');
if (live.noAutoDelete[k] != null) why.add('$label: 미리 볼 수 없다고 함 ${live.noAutoDelete[k]}');
final asked = live.toDelete[k] ?? const <String>[];
for (final g in gone) {
if (!asked.any((a) => g == a || g.startsWith('$a/'))) why.add('$label: rsync 는 지우는데 묻지 않음: $g');
}
for (final a in asked) {
if (!gone.contains(a)) why.add('$label: rsync 는 지우지 않는데 물음: $a');
}
if (asked.isNotEmpty) await live.decideDelete(c.settings.liveSyncPairs.single, delete: true);
for (final d in treeDiff(w, treeOf(dir))) {
why.add('$label: $d');
}
if ((live.pending[k] ?? const []).isNotEmpty) why.add('$label: 맞춘 뒤에도 맞출 것 ${live.pending[k]}');
live.dispose();
}
expect(why, isEmpty, reason: '$opts\n ${why.join('\n ')}');
}, timeout: const Timeout(Duration(minutes: 2)));
}
}

View File

@ -13,6 +13,7 @@ import 'package:path/path.dart' as p;
import 'explorer_test.dart' show act, settle; import 'explorer_test.dart' show act, settle;
import 'support/dav_server.dart'; import 'support/dav_server.dart';
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
/// 95 · 96 · 97 · 100: Rsync 화면의 지우기 - 확인 창 · 미리 보기 · 실행이 같고, ⇄ 는 차례로 (← 는 지우지 않음) /// 95 · 96 · 97 · 100: Rsync 화면의 지우기 - 확인 창 · 미리 보기 · 실행이 같고, ⇄ 는 차례로 (← 는 지우지 않음)
void main() { void main() {
@ -78,7 +79,7 @@ void main() {
expect(find.textContaining('-avPog -u --delete'), findsNothing); expect(find.textContaining('-avPog -u --delete'), findsNothing);
expect(find.textContaining('rsync -avPog -u'), findsWidgets); expect(find.textContaining('rsync -avPog -u'), findsWidgets);
expect(runButton(tester).onPressed, isNotNull); expect(runButton(tester).onPressed, isNotNull);
expect(find.text('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)'), findsOneWidget); expect(find.text(keepOptionsTogether('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)')), findsOneWidget);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '실행'))); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '실행')));
await settle(tester, () => find.byType(LinearProgressIndicator).evaluate().isEmpty && find.textContaining('rsync 끝').evaluate().isNotEmpty, await settle(tester, () => find.byType(LinearProgressIndicator).evaluate().isEmpty && find.textContaining('rsync 끝').evaluate().isNotEmpty,
rounds: 400); rounds: 400);
@ -101,12 +102,12 @@ void main() {
expect(find.textContaining('rsync -avPog --delete'), findsNothing); expect(find.textContaining('rsync -avPog --delete'), findsNothing);
expect(find.textContaining('− b.txt'), findsNothing); expect(find.textContaining('− b.txt'), findsNothing);
// 원본 파일 지우기 (이동 작업) → 그 작업의 옵션 (--delete) 으로 다시 보이고 다시 비교한다 // 원본 파일 지우기 (이동 작업) → 그 작업의 옵션 (--delete) 으로 다시 보이고 다시 비교한다
await tester.tap(find.text('원본 파일 지우기 (--remove-source-files)')); await tester.tap(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')));
await tester.pump(); await tester.pump();
expect(find.textContaining('rsync -avPog --delete --remove-source-files'), findsOneWidget); expect(find.textContaining('rsync -avPog --delete --remove-source-files'), findsOneWidget);
await waitCompare(tester); await waitCompare(tester);
expect(find.textContaining('− b.txt'), findsOneWidget); expect(find.textContaining('− b.txt'), findsOneWidget);
expect(find.text('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)'), findsOneWidget); expect(find.text(keepOptionsTogether('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)')), findsOneWidget);
expect(find.widgetWithText(FilledButton, '이동'), findsOneWidget); expect(find.widgetWithText(FilledButton, '이동'), findsOneWidget);
await tester.tap(find.text('취소')); await tester.tap(find.text('취소'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -118,7 +119,7 @@ void main() {
await open(tester); await open(tester);
await act(tester, () => tester.tap(find.text('좌 → 우'))); await act(tester, () => tester.tap(find.text('좌 → 우')));
await waitCompare(tester); await waitCompare(tester);
expect(find.text('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)'), findsOneWidget); // --del 도 지우기로 알아본다 expect(find.text(keepOptionsTogether('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)')), findsOneWidget); // --del 도 지우기로 알아본다
await tester.tap(find.byTooltip('다시 비교')); await tester.tap(find.byTooltip('다시 비교'));
await tester.pump(); await tester.pump();
expect(runButton(tester).onPressed, isNull); expect(runButton(tester).onPressed, isNull);

View File

@ -23,7 +23,20 @@ void main() {
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: SettingsPage(c: c)))); await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: SettingsPage(c: c))));
await tester.pump(); // 관리자 지시 (10/20, 15): 부하가 크면 (여러 시험을 함께 돌림) 칸들이 시작한 진짜 입출력 (레지스트리 조회 · 구성 요소 목록 ·
// 파일) 이 늦게 끝나 화면이 바뀌거나, 화면을 치운 뒤에야 끝나 예외가 났다 - 화면이 멈출 때까지 (위젯 수가 세 번 같음) 진짜 시간을 흘린다
Future<void> settle() async {
var last = -1, same = 0;
for (var i = 0; i < 150 && same < 3; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
await tester.pump();
final n = find.byType(Widget).evaluate().length;
same = n == last ? same + 1 : 0;
last = n;
}
}
await settle();
final tops = {for (final e in find.byType(ActionChip).evaluate()) tester.getRect(find.byWidget(e.widget)).top.round()}; final tops = {for (final e in find.byType(ActionChip).evaluate()) tester.getRect(find.byWidget(e.widget)).top.round()};
expect(tops, isNotEmpty); expect(tops, isNotEmpty);
if (oneRow) { if (oneRow) {
@ -31,7 +44,8 @@ void main() {
} }
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox()); await tester.pumpWidget(const SizedBox());
await settle(); // 치운 뒤에도 남은 비동기 일이 이 시험 안에서 끝나게
await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1));
}); }, timeout: const Timeout(Duration(minutes: 2))); // 화면이 멈출 때까지 기다리므로 부하가 크면 30초를 넘을 수 있음
} }
} }

View File

@ -16,6 +16,9 @@ import 'support/sftp_server.dart';
/// sftp_next: SFTP 동영상을 받지 않고 이 기기 안의 다리 (127.0.0.1) 로 스트리밍 - 같은 폴더의 다음 동영상 · 그 자막. /// sftp_next: SFTP 동영상을 받지 않고 이 기기 안의 다리 (127.0.0.1) 로 스트리밍 - 같은 폴더의 다음 동영상 · 그 자막.
/// 실제 서버 시험은 tool/sftp_test_server.py (paramiko) - 파이썬이 없으면 건너뛴다 (sftp_test 와 같음) /// 실제 서버 시험은 tool/sftp_test_server.py (paramiko) - 파이썬이 없으면 건너뛴다 (sftp_test 와 같음)
/// 시험 서버 비밀번호 - 무작위 주소에 우연히 들어가지 않게 길고 드물게 (관리자 지시 10/20, 28 이 찾음)
const sftpPw = 'sftp-Pw-3Lm8vZ2w';
void main() { void main() {
final hasServer = sftpTestServerAvailable; final hasServer = sftpTestServerAvailable;
late Directory tmp; late Directory tmp;
@ -26,10 +29,10 @@ void main() {
if (!hasServer) return; if (!hasServer) return;
tmp = Directory.systemTemp.createTempSync('jj_sftpnext_'); tmp = Directory.systemTemp.createTempSync('jj_sftpnext_');
root = Directory(p.join(tmp.path, 'root'))..createSync(); 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; server = s;
SftpRegistry.configure([ 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(); Directory(p.join(root.path, '영상')).createSync();
File(p.join(root.path, '영상', '1화.mkv')).writeAsStringSync('0123456789'); 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, endsWith('/${Uri.encodeComponent('1화.mkv')}'), reason: 'mpv 가 확장자로 형식을 알게');
expect(u1, isNot(equals(u2))); expect(u1, isNot(equals(u2)));
expect(bridge.url('sftp://s1/영상/1화.mkv'), u1, reason: '같은 경로는 같은 주소'); 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); var (code, h, body) = await get(u1);
expect((code, body), (200, '0123456789')); expect((code, body), (200, '0123456789'));

View File

@ -19,6 +19,8 @@ import 'package:jj_mkvmaker/core/remote_stream.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/core/remote_idle.dart';
import 'dart:async';
/// 88: Android SMB (smbj) 의 Dart 쪽 - 플랫폼 채널을 가짜 서버로 (실제 smbj · 서버 연결은 Android 기기 확인 몫) /// 88: Android SMB (smbj) 의 Dart 쪽 - 플랫폼 채널을 가짜 서버로 (실제 smbj · 서버 연결은 Android 기기 확인 몫)
class _FakeSmb { class _FakeSmb {
@ -27,6 +29,9 @@ class _FakeSmb {
final calls = <String>[]; final calls = <String>[];
String? failWith; String? failWith;
bool encrypted = true; bool encrypted = true;
/// 조각 읽기 · 쓰기에 답하지 않음 (NAS 가 멈춘 것처럼 - 원격 응답 기다리기 시험)
bool stall = false;
bool signed = false; bool signed = false;
Map<Object?, Object?> lastArgs = const {}; Map<Object?, Object?> lastArgs = const {};
final _open = <int, (String, bool)>{}; final _open = <int, (String, bool)>{};
@ -39,6 +44,7 @@ class _FakeSmb {
final a = (call.arguments as Map?) ?? const {}; final a = (call.arguments as Map?) ?? const {};
lastArgs = a; lastArgs = a;
if (failWith != null && call.method == 'list') throw PlatformException(code: failWith!, message: 'fake $failWith'); if (failWith != null && call.method == 'list') throw PlatformException(code: failWith!, message: 'fake $failWith');
if (stall && (call.method == 'readChunk' || call.method == 'writeChunk')) return Completer<Object?>().future;
switch (call.method) { switch (call.method) {
case 'info': case 'info':
return {'encrypted': encrypted, 'signed': signed, 'dialect': 'SMB_3_1_1'}; return {'encrypted': encrypted, 'signed': signed, 'dialect': 'SMB_3_1_1'};
@ -118,7 +124,8 @@ void main() {
SmbRegistry.configure(const []); 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 { test('smb:// 로 폴더 · 쓰기 (1MB 조각) · 읽기 · 목록 · 이름 바꾸기 (덮어쓰기) · 지우기 · 화면 이름', () async {
SmbRegistry.configure([server]); SmbRegistry.configure([server]);
@ -190,10 +197,10 @@ void main() {
final secrets = MemorySecretStore(); final secrets = MemorySecretStore();
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
await store.save(AppSettings()..smbServers = [server]); await store.save(AppSettings()..smbServers = [server]);
expect(File(p.join(tmp.path, 'settings.json')).readAsStringSync(), isNot(contains('"pw"'))); expect(File(p.join(tmp.path, 'settings.json')).readAsStringSync(), isNot(contains('smb-Pw-5Qh9kT4r')));
expect(secrets.values['smb.pw.m1'], 'pw'); expect(secrets.values['smb.pw.m1'], 'smb-Pw-5Qh9kT4r');
final back = (await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load()).smbServers.single; 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:// · \\\\서버\\공유 → 서버 추가 창에 채울 (서버, 공유, 폴더)', () { test('경로 입력의 smb:// · \\\\서버\\공유 → 서버 추가 창에 채울 (서버, 공유, 폴더)', () {
@ -299,7 +306,7 @@ void main() {
if (d.isNotEmpty) bad.add('$opts\n ${d.join('\n ')}'); if (d.isNotEmpty) bad.add('$opts\n ${d.join('\n ')}');
} }
expect(bad, isEmpty, reason: bad.join('\n')); expect(bad, isEmpty, reason: bad.join('\n'));
}); }, timeout: const Timeout(Duration(minutes: 5))); // 실제 rsync 28번 - 여러 시험을 함께 돌리면 기본 30초를 넘을 수 있음
test('관리자 지시 (10/20): SMB 동영상 - 같은 폴더 목록 · 다리로 받지 않고 재생 (Range · 건너뛰기) · 다음 화 · 그 자막', () async { test('관리자 지시 (10/20): SMB 동영상 - 같은 폴더 목록 · 다리로 받지 않고 재생 (Range · 건너뛰기) · 다음 화 · 그 자막', () async {
SmbRegistry.configure(const [server]); SmbRegistry.configure(const [server]);
@ -323,7 +330,10 @@ void main() {
final bridge = await RemoteStreamBridge.get(); final bridge = await RemoteStreamBridge.get();
final u1 = bridge.url('smb://m1/영상/1화.mkv'), u2 = bridge.url('smb://m1/영상/2화.mkv'); 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, 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 { Future<(int, String)> get(String url, [String? range]) async {
final http = HttpClient(); final http = HttpClient();
try { try {
@ -344,4 +354,18 @@ void main() {
expect(subs.map(p.basename), ['2화.ko.srt']); expect(subs.map(p.basename), ['2화.ko.srt']);
expect(File(subs.single).readAsStringSync(), contains('둘째')); expect(File(subs.single).readAsStringSync(), contains('둘째'));
}); });
test('관리자 판단 (10/20): SMB 조각 읽기 · 쓰기에 NAS 가 답하지 않으면 정한 시간 뒤 "서버가 답하지 않습니다" (가짜 채널)', () async {
SmbRegistry.configure(const [server]);
RemoteIdle.limit = const Duration(seconds: 1);
addTearDown(() => RemoteIdle.limit = const Duration(seconds: RemoteIdle.defaultSeconds));
fake.files['v.bin'] = List<int>.filled(3000, 1);
fake.stall = true;
final c = SmbRegistry.client('m1');
final sw = Stopwatch()..start();
await expectLater((await c.openReadFrom('v.bin')).drain<void>(), throwsA(isA<RemoteIdleTimeout>()));
await expectLater(c.write('w.bin', Stream.value(List<int>.filled(3000, 2))), throwsA(isA<RemoteIdleTimeout>()));
expect(sw.elapsed, lessThan(const Duration(seconds: 10)));
expect(fake.calls.where((m) => m == 'closeHandle'), hasLength(2), reason: '열린 것은 닫는다');
});
} }

View File

@ -60,8 +60,9 @@ String? get sceneRsync {
} }
/// 장면을 만든다 (있던 것은 지우고). 보내는 쪽 글은 "s:경로", 받는 쪽은 "d:경로" /// 장면을 만든다 (있던 것은 지우고). 보내는 쪽 글은 "s:경로", 받는 쪽은 "d:경로"
void writeScene(String src, String dst) { /// [srcFiles] · [dstFiles] 로 다른 장면도 (받는 쪽 글은 "dd:경로" 로 - 양쪽에 같은 이름이 있으면 크기가 달라 늘 바뀜으로 보이게)
for (final (dir, list, tag) in [(src, sceneSrc, 's'), (dst, sceneDst, 'd')]) { void writeScene(String src, String dst, {List<String> srcFiles = sceneSrc, List<String>? dstFiles}) {
for (final (dir, list, tag) in [(src, srcFiles, 's'), (dst, dstFiles ?? sceneDst, dstFiles == null ? 'd' : 'dd')]) {
final d = Directory(dir); final d = Directory(dir);
if (d.existsSync()) d.deleteSync(recursive: true); if (d.existsSync()) d.deleteSync(recursive: true);
d.createSync(recursive: true); d.createSync(recursive: true);

View File

@ -10,9 +10,19 @@ final _later = <Directory>[];
Directory tempDirUntilEnd(String prefix) { Directory tempDirUntilEnd(String prefix) {
final d = Directory.systemTemp.createTempSync(prefix); final d = Directory.systemTemp.createTempSync(prefix);
_later.add(d); _later.add(d);
expectInTaggedTemp(d);
return d; return d;
} }
/// 관리자 지시 (10/20): tool/test_temp_check.ps1 이 TEMP · TMP 를 실행 폴더 (%TEMP%\t_jjrun_<표>) 로 돌리고 표를 JJ_TEST_TMP_TAG 로 넘긴다.
/// 표가 있는데 임시 폴더가 그 밖에 생기면 (환경 변수가 시험 · 앱까지 오지 않음) 다른 세션 시험과 섞이므로 시험을 실패시킨다
void expectInTaggedTemp(Directory dir) {
final tag = Platform.environment['JJ_TEST_TMP_TAG'] ?? '';
if (tag.isEmpty) return;
expect(dir.absolute.path.toLowerCase(), contains('t_jjrun_$tag'.toLowerCase()),
reason: 'TEMP 가 시험 실행 폴더로 오지 않음 (JJ_TEST_TMP_TAG=$tag)');
}
/// main() 에서 한 번 부른다: 파일의 시험이 다 끝나면 [tempDirUntilEnd] 로 만든 폴더를 지운다 /// main() 에서 한 번 부른다: 파일의 시험이 다 끝나면 [tempDirUntilEnd] 로 만든 폴더를 지운다
/// (늦게 끝난 저장이 다시 만들 수 있어 잠깐씩 기다리며 몇 번 본다) /// (늦게 끝난 저장이 다시 만들 수 있어 잠깐씩 기다리며 몇 번 본다)
void deleteTempDirsAtEnd() { void deleteTempDirsAtEnd() {

View File

@ -50,4 +50,16 @@ void main() {
expect(find.textContaining('(더 새 파일을 덮어씀)'), findsOneWidget); expect(find.textContaining('(더 새 파일을 덮어씀)'), findsOneWidget);
expect(find.textContaining('(받는 쪽이 더 새것 - 건너뜀)'), findsOneWidget); expect(find.textContaining('(받는 쪽이 더 새것 - 건너뜀)'), findsOneWidget);
}); });
test('28 확인 (10/20): keepOptionsTogether - 글 안의 옵션 덩어리만 묶고 (괄호째 · 따로 쓴 것) 나머지는 그대로', () {
const wj = '\u2060', nb = '\u00A0';
expect(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)'), '원본 파일 지우기 (-$wj-${wj}remove-${wj}source-${wj}files)');
expect(keepOptionsTogether('Delete source files (--remove-source-files)'), 'Delete source files (-$wj-${wj}remove-${wj}source-${wj}files)',
reason: '앞 글의 빈칸은 그대로 (거기서는 줄을 바꿀 수 있음)');
expect(keepOptionsTogether('지워집니다 (--delete, 되돌릴 수 없음)'), '지워집니다 (-$wj-${wj}delete,$nb되돌릴$nb수$nb없음)');
expect(keepOptionsTogether('rsync --delete · robocopy /PURGE. 끄면'), 'rsync -$wj-${wj}delete · robocopy /${wj}PURGE. 끄면');
expect(keepOptionsTogether('(rsync 의 -u, robocopy 의 /XO 와 같음)'), '(rsync 의 -${wj}u, robocopy 의 /${wj}XO 와 같음)');
// 낱말 가운데의 - · / 는 그대로 (real-time · a/b)
expect(keepOptionsTogether('real-time 동기화 · a/b · WebDAV'), 'real-time 동기화 · a/b · WebDAV');
});
} }

View File

@ -15,6 +15,7 @@ import 'package:jj_mkvmaker/ui/monitor_page.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'explorer_test.dart' show act, calm, settle; import 'explorer_test.dart' show act, calm, settle;
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
/// 68 · 70 · 71 /// 68 · 70 · 71
void main() { void main() {
@ -204,7 +205,7 @@ void main() {
expect(tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행')).onPressed, isNull); expect(tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행')).onPressed, isNull);
await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 400); await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 400);
// 끝나면: 지울 목록 (빨강) · [실행 (1개 지움)] 켜짐 // 끝나면: 지울 목록 (빨강) · [실행 (1개 지움)] 켜짐
expect(find.text('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)'), findsOneWidget); expect(find.text(keepOptionsTogether('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)')), findsOneWidget);
expect(find.textContaining('− only_r.txt'), findsOneWidget); expect(find.textContaining('− only_r.txt'), findsOneWidget);
expect(tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행')).onPressed, isNotNull); expect(tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행')).onPressed, isNotNull);
await tester.tap(find.text('취소')); await tester.tap(find.text('취소'));

View File

@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/rsync_filter.dart';
import 'package:jj_mkvmaker/core/sync_tools.dart';
import 'package:jj_mkvmaker/ui/sync_preview_view.dart';
/// 관리자 지시 (10/20, 28 지적): 경고 글 안의 옵션 (--filter=H a.txt) 이 좁은 창에서 "--" 와 "filter=…" 로 갈리던 것
void main() {
double widest(String s, {bool min = true}) {
final tp = TextPainter(text: TextSpan(text: s, style: const TextStyle(fontSize: 12)), textDirection: TextDirection.ltr)..layout();
final w = min ? tp.minIntrinsicWidth : tp.maxIntrinsicWidth;
tp.dispose();
return w;
}
test('옵션 글은 줄바꿈 자리가 없다 (가장 좁게 접어도 한 줄 폭)', () {
for (final o in ['--filter=H a.txt', '-f"- *.log"', '--files-from=list/a b.txt', '-C']) {
final u = unbreakableOption(o);
expect(widest(u), closeTo(widest(u, min: false), 0.01), reason: o);
expect(u.replaceAll('\u2060', '').replaceAll('\u00A0', ' '), o, reason: '보이는 글은 그대로');
}
// 묶지 않은 글은 줄이 갈릴 수 있다 (시험이 실제로 무언가를 보는지)
expect(widest('--filter=H a.txt'), lessThan(widest('--filter=H a.txt', min: false)));
});
// 관리자 지시 (10/20): 묶은 글을 복사해 옵션 칸에 붙여 넣어도 rsync 가 같은 인수로 읽는다
test('묶은 옵션 글 · 다른 곳의 글을 붙여 넣어도 인수는 원래대로', () {
const o = '-a --delete --filter="H a.txt" -f"- *.log"';
final pasted = unbreakableOption(o);
expect(pasted, isNot(o));
expect(cleanOptionText(pasted), o);
expect(splitOptions(pasted), splitOptions(o));
expect(splitOptions(pasted), ['-a', '--delete', '--filter=H a.txt', '-f- *.log']);
// 따옴표 안의 줄바꿈 없는 빈칸도 보통 빈칸 (거르기 규칙 "H a.txt" 가 그대로)
expect(RsyncFilter.parse(pasted).unsupported, RsyncFilter.parse(o).unsupported);
expect(RsyncFilter.parse(pasted).excluded('old.log', isDir: false), isTrue);
// 웹 · 문서에서 온 글 (U+202F · U+200B · U+FEFF)
expect(splitOptions('\uFEFF-av\u202F--delete\u200B'), ['-av', '--delete']);
});
testWidgets('옵션 칸 (OptionTextFormatter): 붙여 넣은 묶은 글을 고쳐 받고 커서는 끝에', (tester) async {
final ctl = TextEditingController();
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: TextField(controller: ctl, inputFormatters: const [OptionTextFormatter()]))));
final pasted = unbreakableOption('--filter=H a.txt');
await tester.enterText(find.byType(TextField), pasted);
await tester.pump();
expect(ctl.text, '--filter=H a.txt');
expect(ctl.selection.baseOffset, ctl.text.length);
ctl.dispose();
});
}

View File

@ -150,7 +150,7 @@ void main() {
}); });
test('최신 확인 → 받기 · SHA256 확인 · 압축 풀기', () async { test('최신 확인 → 받기 · SHA256 확인 · 압축 풀기', () async {
final up = GitHubUpdater(apiBase: 'http://127.0.0.1:${server.port}', appDir: tmp.path, versionOverride: '1.0.1'); final up = GitHubUpdater(apiBase: 'http://127.0.0.1:${server.port}', appDir: tmp.path, versionOverride: '1.0.1', tempRoot: tmp.path);
final r = (await up.latest())!; final r = (await up.latest())!;
expect(r.isNewerThan(await up.currentVersion()), isTrue); expect(r.isNewerThan(await up.currentVersion()), isTrue);
final progress = <double>[]; final progress = <double>[];
@ -160,7 +160,7 @@ void main() {
}); });
test('SHA256 이 다르면 설치 거부', () async { test('SHA256 이 다르면 설치 거부', () async {
final up = GitHubUpdater(apiBase: 'http://127.0.0.1:${server.port}', appDir: tmp.path); final up = GitHubUpdater(apiBase: 'http://127.0.0.1:${server.port}', appDir: tmp.path, tempRoot: tmp.path);
final good = (await up.latest())!; final good = (await up.latest())!;
final bad = ReleaseInfo( final bad = ReleaseInfo(
version: good.version, tag: good.tag, name: '', notes: '', pageUrl: '', version: good.version, tag: good.tag, name: '', notes: '', pageUrl: '',
@ -178,7 +178,7 @@ void main() {
Directory(p.join(app.path, 'models')).createSync(); Directory(p.join(app.path, 'models')).createSync();
File(p.join(app.path, 'models', 'm.bin')).writeAsStringSync('model'); File(p.join(app.path, 'models', 'm.bin')).writeAsStringSync('model');
final up = GitHubUpdater(apiBase: 'http://127.0.0.1:${server.port}', appDir: app.path); final up = GitHubUpdater(apiBase: 'http://127.0.0.1:${server.port}', appDir: app.path, tempRoot: tmp.path);
final dir = await up.download((await up.latest())!, (_) {}); final dir = await up.download((await up.latest())!, (_) {});
// 이미 끝난 프로세스를 "앱" 으로 두고 실행 (바로 교체 진행) // 이미 끝난 프로세스를 "앱" 으로 두고 실행 (바로 교체 진행)
final dummy = await Process.start('cmd', ['/c', 'exit']); final dummy = await Process.start('cmd', ['/c', 'exit']);
@ -192,6 +192,9 @@ void main() {
expect(File(p.join(app.path, 'old_only.txt')).readAsStringSync(), 'keep me'); expect(File(p.join(app.path, 'old_only.txt')).readAsStringSync(), 'keep me');
expect(File(p.join(app.path, 'jj_yt-dlp', 'video.mp4')).readAsStringSync(), 'my download'); expect(File(p.join(app.path, 'jj_yt-dlp', 'video.mp4')).readAsStringSync(), 'my download');
expect(File(p.join(app.path, 'models', 'm.bin')).readAsStringSync(), 'model'); expect(File(p.join(app.path, 'models', 'm.bin')).readAsStringSync(), 'model');
// 기록 · 스크립트는 스크립트 옆 (실제로는 %TEMP%, 시험은 시험 폴더 - 시험이 %TEMP% 에 남기지 않음)
expect(File(p.join(tmp.path, 'jj_mkvmaker_update.log')).existsSync(), isTrue);
expect(File(p.join(tmp.path, 'jj_mkvmaker_update.ps1')).existsSync(), isTrue);
}, timeout: const Timeout(Duration(minutes: 2))); }, timeout: const Timeout(Duration(minutes: 2)));
}); });
} }

View File

@ -8,6 +8,10 @@
# 새 버전 확인도 "최신과 같음" 이 된다. 시험판은 flutter build apk --build-number 9xx 로 빌드한다. 정식 판 APK 는 -Release. # 새 버전 확인도 "최신과 같음" 이 된다. 시험판은 flutter build apk --build-number 9xx 로 빌드한다. 정식 판 APK 는 -Release.
# - 순번 900 이상의 v 태그 (시험판 Pre-release, 예: v2026.10.18_977) 는 정식 판으로 보지 않는다 (관리자 지시 10/19, 28 지적: # - 순번 900 이상의 v 태그 (시험판 Pre-release, 예: v2026.10.18_977) 는 정식 판으로 보지 않는다 (관리자 지시 10/19, 28 지적:
# 시험판에 태그를 단 뒤 같은 APK 를 다시 검사하면 "정식 판 번호와 같음" 으로 거짓 실패). 정식 판 (-Release) 인데 순번이 900 이상이면 실패. # 시험판에 태그를 단 뒤 같은 APK 를 다시 검사하면 "정식 판 번호와 같음" 으로 거짓 실패). 정식 판 (-Release) 인데 순번이 900 이상이면 실패.
# - 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] # 사용: powershell -ExecutionPolicy Bypass -File tool\check_apk.ps1 -Apk build\app\outputs\flutter-apk\app-release.apk [-Release]
param( param(
[Parameter(Mandatory = $true)][string]$Apk, [Parameter(Mandatory = $true)][string]$Apk,
@ -74,6 +78,17 @@ $same = @(foreach ($t in $tags) {
if ($t -match '^v(\d+)\.(\d+)\.(\d+)_(\d+)$' -and [long]$Matches[4] -lt 900 -and 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 } ([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) { if ($Release -and ($code % 1000) -ge 900) {
$bad += "정식 판 (-Release) 인데 versionCode $code ($vname) 의 순번이 $($code % 1000) - 시험판 번호입니다 (정식 판은 --build-number 없이, pubspec 의 +순번)" $bad += "정식 판 (-Release) 인데 versionCode $code ($vname) 의 순번이 $($code % 1000) - 시험판 번호입니다 (정식 판은 --build-number 없이, pubspec 의 +순번)"
} }

View File

@ -5,6 +5,10 @@
# - 정식 판 (-Release): 번호가 1000 보다 작아야 한다 (시험 번호가 정식 zip 에 들어가지 않게). # - 정식 판 (-Release): 번호가 1000 보다 작아야 한다 (시험 번호가 정식 zip 에 들어가지 않게).
# - 정식 판 (-Release) 의 순번이 900 이상이면 실패 (900 이상은 시험판 순번 - check_apk.ps1 과 같은 규칙, 관리자 지시 10/19). # - 정식 판 (-Release) 의 순번이 900 이상이면 실패 (900 이상은 시험판 순번 - check_apk.ps1 과 같은 규칙, 관리자 지시 10/19).
# 이 검사는 git 태그를 보지 않으므로 시험판 태그를 단 뒤 다시 검사해도 거짓 실패가 없다. # 이 검사는 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] # 사용: powershell -ExecutionPolicy Bypass -File tool\check_windows_build.ps1 -Dir <배포 폴더 (Lib 가 있는 곳)> [-Release]
param( param(
[Parameter(Mandatory = $true)][string]$Dir, [Parameter(Mandatory = $true)][string]$Dir,
@ -21,6 +25,12 @@ if ($Release) {
if ($build -ge 900) { throw "정식 판인데 시험 번호입니다: $ver (화면 v$shown). --build-number 없이 (pubspec 의 +순번) 빌드하세요" } if ($build -ge 900) { throw "정식 판인데 시험 번호입니다: $ver (화면 v$shown). --build-number 없이 (pubspec 의 +순번) 빌드하세요" }
} elseif ($build -lt 1000) { } elseif ($build -lt 1000) {
throw ("시험판인데 정식 판 번호입니다: $ver (화면 v$shown - 정식 판과 같게 보임).`n" + 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 { '시험판 - 정식 판 번호와 다름' })" "통과: $ver (화면 v$shown) · $(if ($Release) { '정식 판' } else { '시험판 - 정식 판 번호와 다름' })"

View File

@ -8,15 +8,22 @@
""" """
import json import json
import os import os
import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
here = os.path.dirname(os.path.abspath(__file__)) here = os.path.dirname(os.path.abspath(__file__))
out = os.path.join(tempfile.gettempdir(), 'jj_l10n_keys.json') # 관리자 지시 (10/20): 예전에는 %TEMP% 에 jj_l10n_keys.json · jj_l10n_unwrapped.json 을 남겼다 - 임시 폴더를 만들고 끝에 지운다
out2 = os.path.join(tempfile.gettempdir(), 'jj_l10n_unwrapped.json') work = tempfile.mkdtemp(prefix='jj_l10n_check_')
subprocess.run([sys.executable, os.path.join(here, 'extract_keys.py'), out, out2], check=True) try:
keys = json.load(open(out, encoding='utf-8')) out = os.path.join(work, 'keys.json')
out2 = os.path.join(work, 'unwrapped.json')
subprocess.run([sys.executable, os.path.join(here, 'extract_keys.py'), out, out2], check=True)
keys = json.load(open(out, encoding='utf-8'))
unwrapped = json.load(open(out2, encoding='utf-8'))
finally:
shutil.rmtree(work, ignore_errors=True)
missing = 0 missing = 0
for lang in ['en', 'ja', 'zh-Hans']: for lang in ['en', 'ja', 'zh-Hans']:
d = json.load(open(os.path.join('assets', 'l10n', lang + '.json'), encoding='utf-8')) d = json.load(open(os.path.join('assets', 'l10n', lang + '.json'), encoding='utf-8'))
@ -26,7 +33,7 @@ for lang in ['en', 'ja', 'zh-Hans']:
print('%s 사전에 없음: %r' % (lang, k)) print('%s 사전에 없음: %r' % (lang, k))
# 60: tr 로 감싸지 않은 한글 글. 사전에 있으면 tr(변수) 로 번역되는 것으로 본다 - 없으면 번역이 빠진 것 # 60: tr 로 감싸지 않은 한글 글. 사전에 있으면 tr(변수) 로 번역되는 것으로 본다 - 없으면 번역이 빠진 것
en = json.load(open(os.path.join('assets', 'l10n', 'en.json'), encoding='utf-8')) en = json.load(open(os.path.join('assets', 'l10n', 'en.json'), encoding='utf-8'))
loose = [(p, n, t) for p, n, t in json.load(open(out2, encoding='utf-8')) if t not in en] loose = [(p, n, t) for p, n, t in unwrapped if t not in en]
for p, n, t in loose: for p, n, t in loose:
print('번역 안 된 글 (tr 로 감싸거나 사전에 넣기, 자료면 // l10n-skip): %s:%d %r' % (p, n, t[:80])) print('번역 안 된 글 (tr 로 감싸거나 사전에 넣기, 자료면 // l10n-skip): %s:%d %r' % (p, n, t[:80]))
missing += len(loose) missing += len(loose)

View File

@ -1,66 +1,70 @@
# 전체 시험 + 임시 폴더 흔적 검사 (관리자 지시 10/17): 시험이 %TEMP%\jj_* 폴더를 남기지 않는지. # 전체 시험 + 임시 폴더 흔적 검사 (관리자 지시 10/17): 시험이 임시 폴더 (jj_*) 를 남기지 않는지.
# - 시험 전 · 뒤의 %TEMP% 를 비교해, 새로 남은 폴더 중 이름 앞부분이 시험 소스 (test\*.dart) 의 createTemp('jj…') 와 같은 것만 본다 # - 관리자 지시 (10/20): 실행마다 %TEMP%\t_jjrun_<표> 를 만들고 TEMP · TMP 를 그쪽으로 돌려 flutter test 를 돌린다.
# (앱 · 다른 프로그램이 만든 jj_mkvmaker 등은 세지 않음). 하나라도 남으면 이름을 보이고 실패 (exit 2). # 그 폴더 안만 보므로 다른 worktree · 세션이 같은 %TEMP% 에서 동시에 돌린 시험의 폴더가 섞이지 않는다
# - 시험 파일들은 동시에 돌아 파일마다 스스로 확인할 수 없어 (누가 만든 폴더인지 모름) 전체 실행 앞뒤로 본다. # (예전에는 앞뒤 %TEMP% 를 비교해 다른 세션의 jj_explorer_ · jj_sync_ · jj_fake_recycle_ 가 섞여 거짓 실패).
# - 다른 worktree · 세션이 같은 시험을 동시에 돌리면 그쪽 폴더가 섞일 수 있다 (이름으로 확인). # - 앱 코드 (lib) 가 시험 중에 만든 임시 폴더 (jj_up_ · jj_7z_ 등) 도 같은 곳에 생기므로 함께 잡힌다.
# - 이미 있던 폴더는 건드리지 않는다 (지우지 않음). # - 표는 환경 변수 JJ_TEST_TMP_TAG 로도 시험에 넘긴다: 시험 도우미 (test/support/temp_dirs.dart · integration_test/support/temp_dir.dart)
# 가 임시 폴더가 그 안에 생기는지 확인한다 (통합 시험 -d windows 에서 앱까지 환경 변수가 가는지).
# - 끝나면 남은 것을 보이고 실행 폴더를 통째로 지운다. jj 로 시작하는 것 (폴더 · 파일, 앱 고정 이름 포함) 이 남으면 실패 (exit 2)
# (앱이 끝날 때까지 잡힌 jj_held_ 와 jj 가 아닌 것은 참고로만).
# 사용 (app 폴더에서): powershell -ExecutionPolicy Bypass -File tool\test_temp_check.ps1 [flutter test 인자…] # 사용 (app 폴더에서): powershell -ExecutionPolicy Bypass -File tool\test_temp_check.ps1 [flutter test 인자…]
# 통합 시험 · 캡처도 (관리자 지시 10/19, 28 이 찾음): … test_temp_check.ps1 integration_test\windows_b6_shot_test.dart -d windows # 통합 시험 · 캡처도 (관리자 지시 10/19, 28 이 찾음): … test_temp_check.ps1 integration_test\windows_b6_shot_test.dart -d windows
# - 폴더 이름 앞부분은 test 와 integration_test 두 곳의 시험 소스에서 모은다
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$app = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $app = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$temp = [System.IO.Path]::GetTempPath() $temp = [System.IO.Path]::GetTempPath()
# 시험 소스가 쓰는 임시 폴더 이름 앞부분 (createTemp('jj_x_') · createTempSync('jj_x_')) # 표: 짧게 (긴 경로 시험이 경로 길이에 민감하다) - 이름이 jj 로 시작하지 않게 (예전 판 검사가 이 폴더를 시험 폴더로 세지 않게)
# 넘겨받은 시험 파일 (.dart) 이 있으면 그 파일들과 support 폴더만 본다 - 다른 세션 · worktree 가 같은 때 돌린 시험의 폴더를 세지 않게 $tag = -join ((1..6) | ForEach-Object { '{0:x}' -f (Get-Random -Maximum 16) })
# (10/19: b6 캡처 앞뒤에 다른 세션의 단위 시험 폴더 jj_explorer_ · jj_sync_ 가 섞여 거짓 실패) $run = Join-Path $temp "t_jjrun_$tag"
$named = @($args | Where-Object { "$_" -like '*.dart' } | ForEach-Object { Join-Path $app "$_" } | Where-Object { Test-Path $_ }) New-Item -ItemType Directory -Path $run | Out-Null
$sources = if ($named.Count -gt 0) { $saved = @{ TEMP = $env:TEMP; TMP = $env:TMP; JJ_TEST_TMP_TAG = $env:JJ_TEST_TMP_TAG }
# 그 파일들이 가져다 쓰는 support 파일만 함께 (import 'support/…') - 다른 세션의 시험과 이름이 겹치지 않게 (10/19: jj_fake_recycle_) Write-Host "시험 임시 폴더: $run"
$support = @($named | ForEach-Object {
$dir = Split-Path $_
[regex]::Matches((Get-Content $_ -Raw -Encoding utf8), "import '(support/[^']+\.dart)'") |
ForEach-Object { Join-Path $dir $_.Groups[1].Value } })
@($named) + @($support | Where-Object { Test-Path $_ })
} else {
@(@('test', 'integration_test') | ForEach-Object { Join-Path $app $_ } | Where-Object { Test-Path $_ } |
ForEach-Object { Get-ChildItem $_ -Recurse -Filter *.dart | ForEach-Object { $_.FullName } })
}
# 이름이 하나도 없으면 (임시 폴더를 만들지 않는 시험) 새 jj 폴더는 참고로만 보인다
# 이름 앞부분: createTemp('jj_x_') · createTempSync('jj_x_') · testTempDir('jj_x_') (integration_test/support/temp_dir.dart)
$prefixes = @($sources |
ForEach-Object { [regex]::Matches((Get-Content $_ -Raw -Encoding utf8), '(?:createTemp(?:Sync)?|testTempDir)\(''(jj[^''$]*)''') } |
ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique)
if ($prefixes.Count -eq 0 -and $named.Count -eq 0) { throw '시험 소스에서 임시 폴더 이름을 찾지 못했습니다' }
function Get-TestTemp { $env:TEMP = $run
@(Get-ChildItem $temp -Directory -Filter 'jj*' -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) $env:TMP = $run
} $env:JJ_TEST_TMP_TAG = $tag
$before = @{}
foreach ($n in Get-TestTemp) { $before[$n] = $true }
Push-Location $app Push-Location $app
try { try {
flutter test @args flutter test @args
$code = $LASTEXITCODE $code = $LASTEXITCODE
} finally { } finally {
Pop-Location Pop-Location
$env:TEMP = $saved.TEMP
$env:TMP = $saved.TMP
$env:JJ_TEST_TMP_TAG = $saved.JJ_TEST_TMP_TAG
} }
# 시험이 끝난 뒤 지우기가 늦게 끝나는 것 (백신 · 색인) 을 잠깐 기다린다 # 시험이 끝난 뒤 지우기가 늦게 끝나는 것 (백신 · 색인) 을 잠깐 기다린다
Start-Sleep -Seconds 3 Start-Sleep -Seconds 3
$left = @(Get-TestTemp | Where-Object { -not $before.ContainsKey($_) } | Where-Object { $items = @(Get-ChildItem -LiteralPath $run -Force -ErrorAction SilentlyContinue)
$n = $_ # jj_held_ 는 앱이 끝날 때까지 놓지 않는 폴더 (integration_test/support/temp_dir.dart 의 heldByApp - Edge 사용자 데이터) - 보이기만 하고 아래에서 함께 지운다
@($prefixes | Where-Object { $n.StartsWith($_) }).Count -gt 0 $held = @($items | Where-Object { $_.Name -like 'jj_held_*' } | ForEach-Object { $_.Name })
}) # 실패로 세는 것: jj 로 시작하는 것 모두 (폴더 · 파일 - 시험 소스 이름 · 앱 코드의 고정 이름 jj_mkvmaker · jj_mkvmaker_update_* 등).
# 참고: 시험 소스의 이름이 아닌 새 jj 폴더 (앱 코드가 만든 것일 수 있음 - 실패로 세지 않고 보이기만) # 관리자 판단 (10/20): 앱 고정 이름도 사용자 PC 의 실제 %TEMP% 에 쌓이던 것이라 새면 잡는다
$other = @(Get-TestTemp | Where-Object { -not $before.ContainsKey($_) -and $left -notcontains $_ }) $left = @($items | Where-Object { $_.Name -like 'jj*' -and $_.Name -notlike 'jj_held_*' } | ForEach-Object { $_.Name })
# 참고: jj 가 아닌 것 (flutter 도구 · MSBuild 등 - 실패로 세지 않고 보이기만, 실행 폴더와 함께 지움)
$other = @($items | Where-Object { $_.Name -notlike 'jj*' } | ForEach-Object { $_.Name })
if ($other.Count -gt 0) { if ($other.Count -gt 0) {
Write-Host ("참고 - 시험 소스에 없는 이름의 새 jj 폴더 {0}개:" -f $other.Count) Write-Host ("참고 - jj 가 아닌 남은 것 {0}개 (실행 폴더와 함께 지움):" -f $other.Count)
$other | Sort-Object | ForEach-Object { Write-Host " $_" } $other | Sort-Object | ForEach-Object { Write-Host " $_" }
} }
if ($held.Count -gt 0) {
Write-Host ("참고 - 앱이 끝날 때까지 잡혀 있던 것 {0}개 (실행 폴더와 함께 지움):" -f $held.Count)
$held | Sort-Object | ForEach-Object { Write-Host " $_" }
}
if ($left.Count -gt 0) { if ($left.Count -gt 0) {
Write-Host ("시험이 남긴 임시 폴더 {0}개 (%TEMP%):" -f $left.Count) Write-Host ("시험이 남긴 임시 폴더 {0}개 ({1}):" -f $left.Count, $run)
$left | Sort-Object | ForEach-Object { Write-Host " $_" } $left | Sort-Object | ForEach-Object { Write-Host " $_" }
}
# 실행 폴더는 이 스크립트가 만든 것이라 통째로 지운다 (긴 경로는 \\?\ 로).
# 관리자 지시 (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 } if ($code -eq 0) { exit 2 }
} else { } else {
Write-Host '임시 폴더 흔적: 새로 남은 것 0' Write-Host '임시 폴더 흔적: 새로 남은 것 0'