From 7f2263bbb98331543a9ba0d372a5216ce5709f12 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:46:42 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9B=90=EA=B2=A9=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EA=B0=80=20=EB=A9=88=EC=B6=94=EB=A9=B4=20=EC=A0=95=ED=95=9C=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20(=EC=B2=98=EC=9D=8C=2060=EC=B4=88)=20?= =?UTF-8?q?=EB=92=A4=20=EC=8B=A4=ED=8C=A8=20-=20FTP=20=C2=B7=20SFTP=20?= =?UTF-8?q?=C2=B7=20SMB=20=C2=B7=20WebDAV=20=EB=B0=9B=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=98=AC=EB=A6=AC=EA=B8=B0=20=C2=B7=20=EB=AA=85=EB=A0=B9=20(?= =?UTF-8?q?=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 판단 (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 --- assets/l10n/en.json | 4 + assets/l10n/ja.json | 4 + assets/l10n/zh-Hans.json | 4 + .../windows_remote_idle_shot_test.dart | 56 +++ lib/app/app_controller.dart | 3 + lib/app/settings.dart | 7 + lib/core/ftp.dart | 13 +- lib/core/remote_idle.dart | 119 +++++++ lib/core/sftp.dart | 42 ++- lib/core/smb.dart | 10 +- lib/core/vfs.dart | 15 +- lib/core/webdav.dart | 34 +- lib/ui/settings_page.dart | 15 + test/remote_idle_test.dart | 326 ++++++++++++++++++ test/smb_test.dart | 20 ++ 15 files changed, 630 insertions(+), 42 deletions(-) create mode 100644 integration_test/windows_remote_idle_shot_test.dart create mode 100644 lib/core/remote_idle.dart create mode 100644 test/remote_idle_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 8b2ab56..82216e6 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -356,6 +356,7 @@ "VLC 로 열기": "Open with VLC", "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 · 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: 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).", @@ -610,6 +611,7 @@ "{0}장을 올려 저장했습니다{1}": "Upscaled and saved {0}{1}", "{0}줄": "{0} lines", "{0}초": "{0} s", + "{0}초 (기본)": "{0} s (default)", "{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).", "· Android 의 yt-dlp 새 버전 확인 (다운로드 도구 안에서 따로 연결)": "· yt-dlp update check on Android (connects separately inside the download tools)", @@ -1557,6 +1559,7 @@ "서버가 그림을 돌려주지 않았습니다": "The server returned no image", "서버가 너무 오래 답하지 않습니다": "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 is not responding ({0} s)", "서버가 암호화 (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", "서버가 이 기능을 허용하지 않음": "the server does not allow this", @@ -1906,6 +1909,7 @@ "우르두어": "Urdu", "우크라이나어": "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.", + "원격 서버 응답 기다리기": "Wait for remote server response", "원래 이름으로 되돌리지 못한 것: {0}": "Couldn't restore the original names of: {0}", "원래 자리에 같은 이름이 이미 있습니다": "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)", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index bbf38e1..d80dd21 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -356,6 +356,7 @@ "VLC 로 열기": "VLC で開く", "WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失敗: {1} {2}{3}", "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: 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 に従う)。", @@ -610,6 +611,7 @@ "{0}장을 올려 저장했습니다{1}": "{0} ページを高解像度化して保存しました{1}", "{0}줄": "{0}行", "{0}초": "{0}秒", + "{0}초 (기본)": "{0}秒 (既定)", "{0}초 안에 준비되지 않았습니다.": "{0} 秒以内に準備できませんでした。", "{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "{0} 秒ごとに元フォルダーを確認して対象フォルダーに合わせます (アプリの起動中)。", "· Android 의 yt-dlp 새 버전 확인 (다운로드 도구 안에서 따로 연결)": "· Android の yt-dlp 新バージョン確認 (ダウンロードツール内で別に接続)", @@ -1557,6 +1559,7 @@ "서버가 그림을 돌려주지 않았습니다": "サーバーが画像を返しませんでした", "서버가 너무 오래 답하지 않습니다": "サーバーの応答が長時間ありません", "서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "サーバーが別のアドレスへの移動を求めています ({0})。サーバー設定のアドレスを確認してください。", + "서버가 답하지 않습니다 ({0}초)": "サーバーが応答しません ({0} 秒)", "서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "サーバーが暗号化 (FTPS) の開始前に予期しない応答を送りました。接続を切りました", "서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "サーバーが暗号化 (FTPS) に対応していません ({0} {1})。サーバー側で FTPS を有効にするか、リスクを理解した上で「平文 FTP」を選んでください", "서버가 이 기능을 허용하지 않음": "サーバーがこの機能を許可していません", @@ -1906,6 +1909,7 @@ "우르두어": "ウルドゥー語", "우크라이나어": "ウクライナ語", "원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "リモート (WebDAV・SFTP・SMB・FTP) はオプションのフィルター (--exclude・--include・--filter など) に従いません - 除外したものもコピーし、削除を含む場合は削除します。上の比較一覧が実際に行う内容です。", + "원격 서버 응답 기다리기": "リモートサーバーの応答待ち", "원래 이름으로 되돌리지 못한 것: {0}": "元の名前に戻せなかったもの: {0}", "원래 자리에 같은 이름이 이미 있습니다": "元の場所に同じ名前が既にあります", "원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "元のフォルダーがないため元に戻せませんでした (ごみ箱から直接復元してください)", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 061dc08..fac127d 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -356,6 +356,7 @@ "VLC 로 열기": "用 VLC 打开", "WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失败: {1} {2}{3}", "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: 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)。", @@ -610,6 +611,7 @@ "{0}장을 올려 저장했습니다{1}": "已提高并保存 {0} 页{1}", "{0}줄": "{0} 行", "{0}초": "{0} 秒", + "{0}초 (기본)": "{0} 秒 (默认)", "{0}초 안에 준비되지 않았습니다.": "未能在 {0} 秒内准备好。", "{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "每 {0} 秒检查源文件夹并同步到目标文件夹 (应用运行期间)。", "· Android 의 yt-dlp 새 버전 확인 (다운로드 도구 안에서 따로 연결)": "· Android 上的 yt-dlp 新版本检查 (在下载工具内部另行连接)", @@ -1557,6 +1559,7 @@ "서버가 그림을 돌려주지 않았습니다": "服务器没有返回图片", "서버가 너무 오래 답하지 않습니다": "服务器长时间没有响应", "서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "服务器要求改用其他地址 ({0})。请检查服务器设置中的地址。", + "서버가 답하지 않습니다 ({0}초)": "服务器没有响应 ({0} 秒)", "서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "服务器在开始加密 (FTPS) 前发送了意外的回复。已断开连接", "서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "服务器不支持加密 (FTPS) ({0} {1})。请在服务器上启用 FTPS,或在了解风险后选择\"明文 FTP\"", "서버가 이 기능을 허용하지 않음": "服务器不允许此操作", @@ -1906,6 +1909,7 @@ "우르두어": "乌尔都语", "우크라이나어": "乌克兰语", "원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "远程 (WebDAV · SFTP · SMB · FTP) 不遵循选项中的过滤规则 (--exclude · --include · --filter 等) - 被排除的项目也会复制,如包含删除则会删除。上面的比较列表就是实际会执行的操作。", + "원격 서버 응답 기다리기": "等待远程服务器响应", "원래 이름으로 되돌리지 못한 것: {0}": "未能恢复原名称: {0}", "원래 자리에 같은 이름이 이미 있습니다": "原位置已存在同名项目", "원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "原文件夹已不存在,未能恢复 (请在回收站中手动还原)", diff --git a/integration_test/windows_remote_idle_shot_test.dart b/integration_test/windows_remote_idle_shot_test.dart new file mode 100644 index 0000000..f39ec8f --- /dev/null +++ b/integration_test/windows_remote_idle_shot_test.dart @@ -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()); + }); + } + } +} diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index a2b0d2d..0582e13 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -32,6 +32,7 @@ import '../services/platform_services.dart'; import '../services/system_usage.dart'; import 'settings.dart'; import '../l10n/tr.dart'; +import '../core/remote_idle.dart'; extension on Set { Set ifEmpty(Set other) => isEmpty ? other : this; @@ -158,6 +159,8 @@ class AppController extends ChangeNotifier { SftpRegistry.configure(settings.sftpServers); // 88: Android SMB 서버 (smb:// 경로) SmbRegistry.requireSecure = settings.smbRequireSecure; + // 관리자 판단 (10/20): 원격 서버 받기 · 올리기의 조각 사이 기다리기 + RemoteIdle.limit = Duration(seconds: settings.remoteIdleSeconds); SmbRegistry.configure(settings.smbServers); FtpRegistry.configure(settings.ftpServers); outputRootOverride = diff --git a/lib/app/settings.dart b/lib/app/settings.dart index b0d908b..5ccbb90 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -19,6 +19,7 @@ import '../services/image_ai.dart' show AiService; import '../services/app_shell.dart' show appIconOf; import '../services/secret_store.dart'; import '../l10n/tr.dart'; +import '../core/remote_idle.dart'; /// MKV 세부 정보의 이동 버튼 하나: 표시 이름 · 옮길 폴더 class MoveTarget { @@ -621,6 +622,10 @@ class AppSettings { /// l: FTP · FTPS 서버. 비밀번호는 안전 저장소에만 (설정 파일에는 평문으로 쓰지 않는다) List ftpServers = []; + /// 관리자 판단 (10/20): 원격 서버 (FTP · SFTP · SMB · WebDAV) 받기 · 올리기에서 조각 사이를 기다리는 시간 (초). + /// 이 시간 동안 아무것도 오가지 않으면 실패 ("서버가 답하지 않습니다"). 30 · 60 (처음) · 120 · 300 - 그 밖의 값은 60 + int remoteIdleSeconds = RemoteIdle.defaultSeconds; + // ───────── 123 · 121: AI 그림 · 해상도 올리기 ───────── /// 그리는 엔진: 'local' 이 기기 안 (기본) · 아니면 [aiServices] 의 id (사용자가 추가한 서버 · 서비스) @@ -917,6 +922,7 @@ class AppSettings { 'sftpServers': [for (final x in sftpServers) x.toJson()], 'smbServers': [for (final x in smbServers) x.toJson()], 'smbRequireSecure': smbRequireSecure, + 'remoteIdleSeconds': remoteIdleSeconds, 'ftpServers': [for (final x in ftpServers) x.toJson()], 'rsyncOptions': rsyncOptions, 'robocopyOptions': robocopyOptions, @@ -1178,6 +1184,7 @@ class AppSettings { if (x is Map) SftpServer.fromJson(x), ] ..smbRequireSecure = j['smbRequireSecure'] is bool ? j['smbRequireSecure'] as bool : false + ..remoteIdleSeconds = RemoteIdle.choices.contains(j['remoteIdleSeconds']) ? j['remoteIdleSeconds'] as int : RemoteIdle.defaultSeconds ..smbServers = [ for (final x in (j['smbServers'] as List?) ?? const []) if (x is Map) SmbServer.fromJson(x), diff --git a/lib/core/ftp.dart b/lib/core/ftp.dart index 5e8a4c2..10f4c4c 100644 --- a/lib/core/ftp.dart +++ b/lib/core/ftp.dart @@ -10,6 +10,7 @@ import 'dns_resolver.dart'; import 'remote_fs.dart'; import 'secret_gate.dart'; import 'webdav.dart' show DavException, DavItem, DavLockedException; +import 'remote_idle.dart'; /// FTP · FTPS 서버 설정 (관리자 지시 10/17, l). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음). /// [security]: 'explicit' (FTPS - AUTH TLS, 처음 값) · 'implicit' (FTPS 990) · 'none' (평문 FTP - 연결 · 설정에서 "암호화 안 됨" 경고). @@ -380,7 +381,7 @@ class FtpRemote implements RemoteClient { } 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 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); @@ -578,7 +579,8 @@ class FtpRemote implements RemoteClient { Stream> read() async* { var left = length; try { - await for (var chunk in data) { + // 관리자 판단 (10/20): 조각 사이에 정한 시간 동안 아무것도 오지 않으면 실패 (서버가 멈춰도 영원히 기다리지 않게) + await for (var chunk in idleRead(data, onTimeout: data.destroy)) { if (left != null) { if (chunk.length >= left) { yield chunk.sublist(0, left); @@ -614,8 +616,11 @@ class FtpRemote implements RemoteClient { throw s.failure(code, text, rel); } final sock = await s.secureData(plain); - await sock.addStream(data); - await sock.flush(); + // 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하면 실패 (연결을 끊음) + await idleWrite(data, (x) async { + await sock.addStream(x); + await sock.flush(); + }, onTimeout: sock.destroy); // 닫기 (TLS 는 close_notify) 를 보내고, 서버가 답하지 않아도 오래 기다리지 않게 연결째 끊는다 // 다 보낸 뒤 닫는다 (TLS 는 close_notify). close() 가 끝나도 암호화된 마지막 자료가 아직 나가지 않았을 수 있어 // 연결이 정말 끝날 때 (done) 까지 기다리고, 서버가 답하지 않을 때만 끊는다 (곧바로 destroy 하면 빈 파일이 되었다) diff --git a/lib/core/remote_idle.dart b/lib/core/remote_idle.dart new file mode 100644 index 0000000..1cf06a4 --- /dev/null +++ b/lib/core/remote_idle.dart @@ -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 idleRead(Stream s, {Duration? limit, void Function()? onTimeout}) { + final d = limit ?? RemoteIdle.limit; + late StreamController out; + StreamSubscription? 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( + 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 idleWrite(Stream> data, Future Function(Stream> s) send, + {Duration? limit, void Function()? onTimeout}) { + final d = limit ?? RemoteIdle.limit; + final fail = Completer(); + 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 idleCall(Future f, {Duration? limit, void Function()? onTimeout}) { + final d = limit ?? RemoteIdle.limit; + // 시간이 지나 연결을 끊은 뒤 원래 요청이 늦게 실패해도 (연결 닫힘 등) 처리되지 않은 오류로 남지 않게 + f.ignore(); + return f.timeout(d, onTimeout: () { + onTimeout?.call(); + throw RemoteIdleTimeout(d.inSeconds); + }); +} diff --git a/lib/core/sftp.dart b/lib/core/sftp.dart index b0bbdf0..ca5aeaa 100644 --- a/lib/core/sftp.dart +++ b/lib/core/sftp.dart @@ -10,6 +10,7 @@ import 'dns_resolver.dart'; import 'remote_fs.dart'; import 'secret_gate.dart'; import 'webdav.dart' show DavException, DavItem, DavLockedException; +import 'remote_idle.dart'; /// 88: SFTP 서버 설정 (환경 설정 > 파일 탐색기 > 네트워크 서버). 비밀번호 · 개인 키 · 키 암호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음). /// 호스트 키 (지문) 는 비밀이 아니라 설정에 둔다 - 처음 연결 때 사용자가 믿은 것 (TOFU). @@ -266,8 +267,13 @@ class SftpRemote implements RemoteClient { return x; } + /// 관리자 판단 (10/20): SFTP 명령 하나 (목록 · 열기 · 닫기 · 지우기 등) 를 정한 시간 ([RemoteIdle]) 까지만 기다린다 - + /// 서버가 멈추면 이 연결을 닫고 (다음에 다시 연다) "서버가 답하지 않습니다" + Future _cmd(Future f) => idleCall(f, onTimeout: close); + /// 파일 다루다 난 오류 (SFTP 상태) → 사람 말. 끊긴 연결은 다음에 다시 연다 Never _fail(Object e, String rel) { + if (e is RemoteIdleTimeout) throw e; if (e is SftpStatusError) { switch (e.code) { case SftpStatusCode.noSuchFile: @@ -298,13 +304,13 @@ class SftpRemote implements RemoteClient { final c = await _open(); try { final out = []; - 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; var a = n.attr; // 링크는 가리키는 것으로 (폴더 링크를 폴더로) - 따라갈 수 없으면 크기 0 파일로 if (a.type == SftpFileType.symbolicLink) { 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 (_) {} } out.add(_item(_child(rel, n.filename), a)); @@ -319,7 +325,7 @@ class SftpRemote implements RemoteClient { Future stat(String rel) async { final c = await _open(); 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) { if (e.code == SftpStatusCode.noSuchFile) return null; _fail(e, rel); @@ -339,7 +345,7 @@ class SftpRemote implements RemoteClient { await mkdirs(_parent(rel)); final c = await _open(); try { - await c.sftp.mkdir(_remote(c, rel)); + await _cmd(c.sftp.mkdir(_remote(c, rel))); } catch (e) { // 그 사이에 다른 곳이 만들었으면 그대로 if ((await stat(rel))?.isDir == true) return; @@ -357,9 +363,9 @@ class SftpRemote implements RemoteClient { for (final x in await list(rel)) { await delete(x.rel); } - await c.sftp.rmdir(_remote(c, rel)); + await _cmd(c.sftp.rmdir(_remote(c, rel))); } else { - await c.sftp.remove(_remote(c, rel)); + await _cmd(c.sftp.remove(_remote(c, rel))); } } catch (e) { _fail(e, rel); @@ -374,14 +380,14 @@ class SftpRemote implements RemoteClient { final t = await stat(to); if (t != null && !t.isDir) { try { - await c.sftp.remove(_remote(c, to)); + await _cmd(c.sftp.remove(_remote(c, to))); } catch (e) { _fail(e, to); } } } try { - await c.sftp.rename(_remote(c, from), _remote(c, to)); + await _cmd(c.sftp.rename(_remote(c, from), _remote(c, to))); } catch (e) { _fail(e, from); } @@ -395,7 +401,7 @@ class SftpRemote implements RemoteClient { final c = await _open(); final SftpFile f; try { - f = await c.sftp.open(_remote(c, rel)); + f = await _cmd(c.sftp.open(_remote(c, rel))); } catch (e) { _fail(e, rel); } @@ -403,14 +409,15 @@ class SftpRemote implements RemoteClient { StreamSubscription? sub; out = StreamController>( 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 { _fail(e, rel); } catch (x) { out.addError(x); } }, onDone: () async { - await f.close().catchError((_) {}); + await _cmd(f.close()).catchError((_) {}); await out.close(); }); }, @@ -418,7 +425,7 @@ class SftpRemote implements RemoteClient { onResume: () => sub?.resume(), onCancel: () async { await sub?.cancel(); - await f.close().catchError((_) {}); + await _cmd(f.close()).catchError((_) {}); }, ); return out.stream; @@ -429,17 +436,18 @@ class SftpRemote implements RemoteClient { final c = await _open(); final SftpFile f; try { - f = await c.sftp.open(_remote(c, rel), - mode: SftpFileOpenMode.create | SftpFileOpenMode.truncate | SftpFileOpenMode.write); + f = await _cmd(c.sftp.open(_remote(c, rel), + mode: SftpFileOpenMode.create | SftpFileOpenMode.truncate | SftpFileOpenMode.write)); } catch (e) { _fail(e, rel); } 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) { _fail(e, rel); } finally { - await f.close().catchError((_) {}); + await _cmd(f.close()).catchError((_) {}); } } @@ -447,7 +455,7 @@ class SftpRemote implements RemoteClient { Future<(int?, int?)> quota() async { final c = await _open(); 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; return (v.freeBlocksForNonRoot * unit, (v.totalBlocks - v.freeBlocks) * unit); } catch (_) { diff --git a/lib/core/smb.dart b/lib/core/smb.dart index 52d639c..405b26c 100644 --- a/lib/core/smb.dart +++ b/lib/core/smb.dart @@ -10,6 +10,7 @@ import '../l10n/tr.dart'; import 'remote_fs.dart'; import 'secret_gate.dart'; import 'webdav.dart' show DavException, DavItem, DavLockedException; +import 'remote_idle.dart'; /// 88: SMB 서버 설정 (Android 만 - Windows 는 운영체제가 \\서버\공유 를 연다, 사용자 결정 88-1). /// 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음). @@ -245,8 +246,9 @@ class SmbRemote implements RemoteClient { while (left == null || left > 0) { final Uint8List? data; try { - data = await channel.invokeMethod( - 'readChunk', {'handle': handle, 'offset': offset, 'length': left == null ? chunk : min(chunk, left)}); + // 관리자 판단 (10/20): 한 조각을 정한 시간 안에 받지 못하면 실패 (NAS 가 멈춰도 영원히 기다리지 않게) + data = await idleCall(channel.invokeMethod( + 'readChunk', {'handle': handle, 'offset': offset, 'length': left == null ? chunk : min(chunk, left)})); } on PlatformException catch (e) { throw _explain(e, rel); } @@ -276,8 +278,8 @@ class SmbRemote implements RemoteClient { while (bytes.length - at >= chunk || (all && at < bytes.length)) { final end = (at + chunk).clamp(0, bytes.length); try { - await channel.invokeMethod( - 'writeChunk', {'handle': handle, 'offset': offset, 'data': Uint8List.sublistView(bytes, at, end)}); + await idleCall(channel.invokeMethod( + 'writeChunk', {'handle': handle, 'offset': offset, 'data': Uint8List.sublistView(bytes, at, end)})); } on PlatformException catch (e) { throw _explain(e, rel); } diff --git a/lib/core/vfs.dart b/lib/core/vfs.dart index 3d61dac..95333ff 100644 --- a/lib/core/vfs.dart +++ b/lib/core/vfs.dart @@ -9,6 +9,7 @@ import 'sftp.dart'; import 'smb.dart'; import 'webdav.dart'; import '../l10n/tr.dart'; +import 'remote_idle.dart'; /// 로컬 경로와 원격 경로 (WebDAV `dav://<서버 id>/<경로>` · 88 SFTP `sftp://<서버 id>/<경로>`) 를 같이 다루는 도구. /// 로컬 경로는 원래 코드 (dart:io) 를 그대로 쓰고, 원격만 여기서 서버 종류에 맞는 클라이언트 ([RemoteClient]) 로 처리한다. @@ -235,8 +236,9 @@ Future vWrite(String path, Stream> data, {int? length, DateTime? try { await d.client.write(tmp.rel, data, length: length); } catch (_) { + // 받다 만 .jjpart 를 지운다 - 서버가 멈췄으면 지우기도 답이 없을 수 있어 정한 시간까지만 (못 지우면 다음 정리 (71) 에서) try { - await d.client.delete(tmp.rel); + await idleCall(d.client.delete(tmp.rel)); } catch (_) {} rethrow; } @@ -248,15 +250,18 @@ Future vWrite(String path, Stream> data, {int? length, DateTime? try { await out.addStream(data); } catch (_) { - // 실패 · 취소: 반쯤 쓴 임시 파일을 남기지 않는다 (원래 파일은 그대로) - await out.close(); + // 실패 · 취소: 반쯤 쓴 임시 파일을 남기지 않는다 (원래 파일은 그대로). + // 10/20 (15, 멈추는 서버 시험으로 찾음): 예전에는 finally 에서 한 번 더 닫다가 난 "File closed" 가 진짜 이유 + // (서버가 답하지 않습니다 · 연결 끊김 등) 를 덮어썼다 - 닫기 · 지우기의 오류는 삼키고 원래 오류를 그대로 + try { + await out.close(); + } catch (_) {} try { await File(tmp).delete(); } catch (_) {} rethrow; - } finally { - await out.close(); } + await out.close(); if (await File(path).exists()) await File(path).delete(); await File(tmp).rename(path); if (modified != null) { diff --git a/lib/core/webdav.dart b/lib/core/webdav.dart index 65f73fb..48f4ec7 100644 --- a/lib/core/webdav.dart +++ b/lib/core/webdav.dart @@ -7,6 +7,7 @@ import 'package:xml/xml.dart'; import '../l10n/tr.dart'; import 'remote_fs.dart'; import 'secret_gate.dart'; +import 'remote_idle.dart'; /// WebDAV 서버 설정 (환경 설정 > 파일 탐색기 > WebDAV). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음). class DavServer { @@ -115,6 +116,10 @@ class DavClient implements RemoteClient { ? null : 'Basic ${base64Encode(utf8.encode('${server.user}:${server.password}'))}'; + /// 관리자 판단 (10/20): 답 (머리) 을 정한 시간 ([RemoteIdle]) 까지만 기다린다 - 서버가 멈추면 그 연결을 끊고 "서버가 답하지 않습니다". + /// 답의 몸은 [idleRead] 로 (조각 사이) + Future _send(HttpClientRequest req) => idleCall(req.close(), onTimeout: req.abort); + Future _open(String method, Uri uri) async { // 124: 저장된 비밀번호로 접속하기 전에 (마스터 비밀번호를 정해 두었으면 묻는다) if (server.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage)); @@ -164,8 +169,8 @@ class DavClient implements RemoteClient { final bytes = utf8.encode(body); req.contentLength = bytes.length; req.add(bytes); - final res = await req.close(); - final text = await res.transform(utf8.decoder).join(); + final res = await _send(req); + final text = await idleRead(res).transform(utf8.decoder).join(); if (res.statusCode == 404) throw DavException(trf('없는 경로: {0}', [rel]), status: 404); if (res.statusCode != 207 && res.statusCode != 200) _fail(tr('목록'), res, text); return text; @@ -231,8 +236,8 @@ class DavClient implements RemoteClient { /// 폴더 만들기 (이미 있으면 그대로) Future mkdir(String rel) async { - final res = await (await _open('MKCOL', uriOf(rel, dir: true))).close(); - await res.drain(); + final res = await _send(await _open('MKCOL', uriOf(rel, dir: true))); + await idleRead(res).drain(); if (res.statusCode == 201 || res.statusCode == 200) return; if (res.statusCode == 405 && (await stat(rel))?.isDir == true) return; // 이미 있음 _fail(tr('폴더 만들기'), res); @@ -252,8 +257,8 @@ class DavClient implements RemoteClient { @override Future delete(String rel) async { final st = await stat(rel); - final res = await (await _open('DELETE', uriOf(rel, dir: st?.isDir ?? false))).close(); - await res.drain(); + final res = await _send(await _open('DELETE', uriOf(rel, dir: st?.isDir ?? false))); + await idleRead(res).drain(); if (res.statusCode == 404) return; 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('Overwrite', overwrite ? 'T' : 'F'); if (st?.isDir ?? false) req.headers.set('Depth', 'infinity'); - final res = await req.close(); - await res.drain(); + final res = await _send(req); + await idleRead(res).drain(); if (res.statusCode >= 300) _fail(method == 'MOVE' ? tr('이동') : tr('복사'), res); } @@ -277,12 +282,14 @@ class DavClient implements RemoteClient { /// 받기: 내용을 조각으로 (끝나면 스트림이 닫힘) @override Future>> 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) { await res.drain(); _fail(tr('받기'), res); } - return res; + return idleRead(res); } /// 올리기 ([length] 를 알면 Content-Length 로) @@ -290,8 +297,11 @@ class DavClient implements RemoteClient { Future write(String rel, Stream> data, {int? length}) async { final req = await _open('PUT', uriOf(rel)); if (length != null) req.contentLength = length; - await req.addStream(data); - final res = await req.close(); + // 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하거나 다 보낸 뒤 답이 없으면 실패 (연결을 끊음) + final res = await idleWrite(data, (x) async { + await req.addStream(x); + return req.close(); + }, onTimeout: req.abort); await res.drain(); if (res.statusCode >= 300) _fail(tr('올리기'), res); } diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 3a88ea3..676b9d5 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -45,6 +45,7 @@ import 'toast_status.dart'; import 'license_texts.dart'; import 'subtitle_language_picker.dart'; import 'hidden_items_dialog.dart'; +import '../core/remote_idle.dart'; /// 동시 작업 수 고르기: 1 · 5 · 10 · 무한(0) · 직접 입력 class CountSelector extends StatelessWidget { @@ -926,6 +927,20 @@ class _SettingsPageState extends State { FtpSettings(c: c), // 88: SMB (Android 만 - Windows 는 운영체제가 \\서버\공유 를 연다) 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( + 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), ]), // Rsync 화면: rsync 옵션 · 가져오기 · 실시간 동기화 (lsync) · 백그라운드로 실행 diff --git a/test/remote_idle_test.dart b/test/remote_idle_test.dart new file mode 100644 index 0000000..2413193 --- /dev/null +++ b/test/remote_idle_test.dart @@ -0,0 +1,326 @@ +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> _big() async* { + final chunk = List.filled(64 * 1024, 7); + for (var i = 0; i < 4096; i++) { + yield chunk; // 최대 256MB + } +} + +/// 걸린 시간 · 결과 오류 +Future<(Duration, Object?)> _timed(Future 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 = []; + final _servers = []; + Socket? _data; + int get port => _ctl.port; + + Future 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.delayed(const Duration(milliseconds: 20)); + } + _data?.add(List.filled(1000, 65)); // 조금 보내고 멈춤 (닫지 않음) + case 'STOR': + say('150 receiving'); + case 'QUIT': + say('221 bye'); + default: + say('200 ok'); + } + }, onError: (_) {}); + }); + } + + Future 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 = []; + bool stall = false; + + Future 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 connect(String host, int port, Duration timeout) => Socket.connect(InternetAddress.loopbackIPv4, _ss.port, timeout: timeout); + + Future 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>(); + var cut = false; + c.add([1]); + final (t, e) = await _timed(() => idleRead(c.stream, onTimeout: () => cut = true).drain()); + expect(e, isA()); + expect(t, lessThan(const Duration(seconds: 5))); + expect(cut, isTrue); + await c.close(); + // 0.4초마다 조각 (정한 1초 안) - 끝까지 + Stream> slow() async* { + for (var i = 0; i < 5; i++) { + await Future.delayed(const Duration(milliseconds: 400)); + yield [i]; + } + } + + expect(await idleRead(slow()).length, 5); + }); + + test('도우미: 올리기 - 보내는 쪽이 더 가져가지 않으면 · 다 가져간 뒤 답이 없으면 실패', () async { + var cut = false; + final (t, e) = await _timed(() => idleWrite(_big(), (s) async { + await s.first; // 한 조각만 가져가고 멈춤 + await Completer().future; + }, onTimeout: () => cut = true)); + expect(e, isA()); + expect(t, lessThan(const Duration(seconds: 5))); + expect(cut, isTrue); + final (_, e2) = await _timed(() => idleWrite(Stream.value([1]), (s) async { + await s.drain(); + await Completer().future; // 마지막 답이 없음 + })); + expect(e2, isA()); + 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(), 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(), reason: '$e'); + expect(t, lessThan(const Duration(seconds: 15))); + }); + }); + + group('WebDAV (멈추는 시험 서버)', () { + late HttpServer http; + final seen = []; + 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.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()); + expect(e, isA(), reason: '$e'); + expect(t, lessThan(const Duration(seconds: 10))); + final (t2, e2) = await _timed(() async => (await vOpenRead('dav://st/nohead.bin')).drain()); + expect(e2, isA(), 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(), 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.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.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); + expect((await c.list('/')).map((e) => e.rel), contains('/a.bin')); + final s = await c.openReadFrom('a.bin'); // 열기까지는 됨 + proxy!.stall = true; // 이제 서버가 아무것도 보내지 않음 + final (t, e) = await _timed(() => s.drain()); + expect(e, isA(), reason: '$e'); + expect(t, lessThan(const Duration(seconds: 10))); + final c2 = client(); + addTearDown(c2.close); + proxy!.stall = false; + await c2.list('/'); + proxy!.stall = true; + final (t2, e2) = await _timed(() => c2.openReadFrom('a.bin')); + expect(e2, isA(), reason: '$e2'); + expect(t2, lessThan(const Duration(seconds: 10))); + }, skip: has ? false : 'SFTP 시험 서버 (paramiko) 없음'); + + test('올리는 중에 서버가 멈추면 정한 시간 뒤 실패', () async { + final c = client(); + addTearDown(c.close); + await c.list('/'); + proxy!.stall = true; + final (t, e) = await _timed(() => c.write('b.bin', _big())); + expect(e, isA(), reason: '$e'); + expect(t, lessThan(const Duration(seconds: 10))); + }, skip: has ? false : 'SFTP 시험 서버 (paramiko) 없음'); + }); +} diff --git a/test/smb_test.dart b/test/smb_test.dart index 2366f69..1ce69d4 100644 --- a/test/smb_test.dart +++ b/test/smb_test.dart @@ -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/process_media_tool.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 기기 확인 몫) class _FakeSmb { @@ -27,6 +29,9 @@ class _FakeSmb { final calls = []; String? failWith; bool encrypted = true; + + /// 조각 읽기 · 쓰기에 답하지 않음 (NAS 가 멈춘 것처럼 - 원격 응답 기다리기 시험) + bool stall = false; bool signed = false; Map lastArgs = const {}; final _open = {}; @@ -39,6 +44,7 @@ class _FakeSmb { final a = (call.arguments as Map?) ?? const {}; lastArgs = a; if (failWith != null && call.method == 'list') throw PlatformException(code: failWith!, message: 'fake $failWith'); + if (stall && (call.method == 'readChunk' || call.method == 'writeChunk')) return Completer().future; switch (call.method) { case 'info': return {'encrypted': encrypted, 'signed': signed, 'dialect': 'SMB_3_1_1'}; @@ -344,4 +350,18 @@ void main() { expect(subs.map(p.basename), ['2화.ko.srt']); 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.filled(3000, 1); + fake.stall = true; + final c = SmbRegistry.client('m1'); + final sw = Stopwatch()..start(); + await expectLater((await c.openReadFrom('v.bin')).drain(), throwsA(isA())); + await expectLater(c.write('w.bin', Stream.value(List.filled(3000, 2))), throwsA(isA())); + expect(sw.elapsed, lessThan(const Duration(seconds: 10))); + expect(fake.calls.where((m) => m == 'closeHandle'), hasLength(2), reason: '열린 것은 닫는다'); + }); }