원격 서버가 멈추면 정한 시간 (처음 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>remote_idle
parent
d7da812da6
commit
7f2263bbb9
|
|
@ -356,6 +356,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).",
|
||||||
|
|
@ -610,6 +611,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)",
|
||||||
|
|
@ -1557,6 +1559,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",
|
||||||
|
|
@ -1906,6 +1909,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)",
|
||||||
|
|
|
||||||
|
|
@ -356,6 +356,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 に従う)。",
|
||||||
|
|
@ -610,6 +611,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 新バージョン確認 (ダウンロードツール内で別に接続)",
|
||||||
|
|
@ -1557,6 +1559,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」を選んでください",
|
||||||
"서버가 이 기능을 허용하지 않음": "サーバーがこの機能を許可していません",
|
"서버가 이 기능을 허용하지 않음": "サーバーがこの機能を許可していません",
|
||||||
|
|
@ -1906,6 +1909,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}",
|
||||||
"원래 자리에 같은 이름이 이미 있습니다": "元の場所に同じ名前が既にあります",
|
"원래 자리에 같은 이름이 이미 있습니다": "元の場所に同じ名前が既にあります",
|
||||||
"원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "元のフォルダーがないため元に戻せませんでした (ごみ箱から直接復元してください)",
|
"원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "元のフォルダーがないため元に戻せませんでした (ごみ箱から直接復元してください)",
|
||||||
|
|
|
||||||
|
|
@ -356,6 +356,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)。",
|
||||||
|
|
@ -610,6 +611,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 新版本检查 (在下载工具内部另行连接)",
|
||||||
|
|
@ -1557,6 +1559,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\"",
|
||||||
"서버가 이 기능을 허용하지 않음": "服务器不允许此操作",
|
"서버가 이 기능을 허용하지 않음": "服务器不允许此操作",
|
||||||
|
|
@ -1906,6 +1909,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}",
|
||||||
"원래 자리에 같은 이름이 이미 있습니다": "原位置已存在同名项目",
|
"원래 자리에 같은 이름이 이미 있습니다": "原位置已存在同名项目",
|
||||||
"원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "原文件夹已不存在,未能恢复 (请在回收站中手动还原)",
|
"원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "原文件夹已不存在,未能恢复 (请在回收站中手动还原)",
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,7 @@ import '../services/platform_services.dart';
|
||||||
import '../services/system_usage.dart';
|
import '../services/system_usage.dart';
|
||||||
import 'settings.dart';
|
import 'settings.dart';
|
||||||
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 +159,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 =
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -621,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 (사용자가 추가한 서버 · 서비스)
|
||||||
|
|
@ -917,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,
|
||||||
|
|
@ -1178,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),
|
||||||
|
|
|
||||||
|
|
@ -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 하면 빈 파일이 되었다)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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 (_) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) · 백그라운드로 실행
|
||||||
|
|
|
||||||
|
|
@ -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<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.4초마다 조각 (정한 1초 안) - 끝까지
|
||||||
|
Stream<List<int>> slow() async* {
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
await Future<void>.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<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);
|
||||||
|
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<void>());
|
||||||
|
expect(e, isA<RemoteIdleTimeout>(), 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<RemoteIdleTimeout>(), 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<RemoteIdleTimeout>(), reason: '$e');
|
||||||
|
expect(t, lessThan(const Duration(seconds: 10)));
|
||||||
|
}, skip: has ? false : 'SFTP 시험 서버 (paramiko) 없음');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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'};
|
||||||
|
|
@ -344,4 +350,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: '열린 것은 닫는다');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue