보안 · 비밀번호 (124 · 127~132) · 쿠키 (53) · http 경고 (56) · 안내 고침 (116 · 125 · 126) · 시험 안정화
- 124: 최상 · 마스터 비밀번호 (솔트 PBKDF2-HMAC-SHA256, 안전 저장소에 해시만), 묻는 때 (안전 저장소), 저장된 비밀번호를 쓰는 곳의 문 (WebDAV · OpenSubtitles · 재생 · 환경 설정), 최상으로 초기화 · 잊었을 때 초기화, 틀리면 기다리기, 서버 지우기 전 쓰는 곳 알림, 실시간 동기화 폴더에 WebDAV 고르기 - 127: 서버 고치기 창은 마스터 뒤에 (저장된 비밀번호가 보이지 않게) - 128: 마스터 창은 하나로 · 취소하면 배경 작업은 묻지 않고 "기다리는 중" 으로 표시 - 129: 묻는 때를 설정 파일이 아니라 안전 저장소에 (값이 없거나 틀리면 쓸 때 묻기) - 130: Windows secrets.dat 를 쓰기 · 지우기 전에 다시 읽어 다른 창의 값과 합침 - 131: 앱 안 브라우저 쿠키를 쓰지 않게 바꾸면 내보낸 쿠키 파일을 지움 - 132: 최상 비밀번호 [끄기] 가 넓은 화면에서 잘리지 않게 - 53: 쿠키 파일 안내 · 넘길 사이트 고르기 · 쿠키 · 방문 기록 지우기 · 범위 넓힘 알림 - 56: http:// WebDAV 경고 · 인증서 확인 끄기가 다른 재생에 남지 않게 - 116 · 125 · 126: 권한 안내 다시 · "2-1 / 3장" · 바뀐 기본 설정 창을 닫으면 바로 저장 - 시험판 데이터 폴더 (JJ_MKVMAKER_DATA, Windows) · 휴지통 판정을 $I 정보로 · 모니터링 시험 기다림 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>묶음3
parent
597d853943
commit
70056b0db3
|
|
@ -154,9 +154,11 @@
|
||||||
"SRT 형식이 아니거나 비어 있습니다.": "Not in SRT format or empty.",
|
"SRT 형식이 아니거나 비어 있습니다.": "Not in SRT format or empty.",
|
||||||
"VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "VLC is not installed. Once installed it appears in the list (videolan.org).",
|
"VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "VLC is not installed. Once installed it appears in the list (videolan.org).",
|
||||||
"VLC 로 열기": "Open with VLC",
|
"VLC 로 열기": "Open with VLC",
|
||||||
|
"WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV, OpenSubtitles, etc. Server list and user names are kept",
|
||||||
"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 서버 \"{0}\" 을(를) 지울까요?": "Delete WebDAV server \"{0}\"?",
|
"WebDAV 서버 \"{0}\" 을(를) 지울까요?": "Delete WebDAV server \"{0}\"?",
|
||||||
"WebDAV 서버 고치기": "Edit WebDAV server",
|
"WebDAV 서버 고치기": "Edit WebDAV server",
|
||||||
|
"WebDAV 서버 목록과 그 저장된 비밀번호도 지워집니다. 동영상 목록 · 즐겨찾기 · 파일은 그대로입니다": "The WebDAV server list and its saved passwords are deleted too. The video list, bookmarks and files are kept",
|
||||||
"WebDAV 서버 추가": "Add WebDAV server",
|
"WebDAV 서버 추가": "Add WebDAV server",
|
||||||
"WebDAV 에서 받는 중": "Downloading from WebDAV",
|
"WebDAV 에서 받는 중": "Downloading from WebDAV",
|
||||||
"WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "Used for WebDAV sources (PC folders sync as soon as they change). Shorter means faster syncing but more server and battery use.",
|
"WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "Used for WebDAV sources (PC folders sync as soon as they change). Shorter means faster syncing but more server and battery use.",
|
||||||
|
|
@ -203,6 +205,7 @@
|
||||||
"cron (분 시 일 월 요일) · 한 줄에 하나": "cron (minute hour day month weekday) · one per line",
|
"cron (분 시 일 월 요일) · 한 줄에 하나": "cron (minute hour day month weekday) · one per line",
|
||||||
"ffmpeg 를 실행할 수 없습니다: {0}": "Cannot run ffmpeg: {0}",
|
"ffmpeg 를 실행할 수 없습니다: {0}": "Cannot run ffmpeg: {0}",
|
||||||
"ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg error (code {0})\n{1}",
|
"ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg error (code {0})\n{1}",
|
||||||
|
"http:// 주소는 아이디 · 비밀번호와 파일이 암호화되지 않고 그대로 갑니다. 같은 네트워크의 다른 사람이 볼 수 있으니 서버가 https 를 지원하면 https:// 로 바꾸세요.": "With an http:// address, the user name, password and files are sent unencrypted. Others on the same network can see them, so switch to https:// if the server supports it.",
|
||||||
"lsync (실시간 동기화)": "lsync (live sync)",
|
"lsync (실시간 동기화)": "lsync (live sync)",
|
||||||
"lsync 로 이동": "Move to lsync",
|
"lsync 로 이동": "Move to lsync",
|
||||||
"quit 시작": "quit started",
|
"quit 시작": "quit started",
|
||||||
|
|
@ -232,6 +235,7 @@
|
||||||
"{0} (AI 번역 ← {1})": "{0} (AI translation ← {1})",
|
"{0} (AI 번역 ← {1})": "{0} (AI translation ← {1})",
|
||||||
"{0} (기본): {1}": "{0} (default): {1}",
|
"{0} (기본): {1}": "{0} (default): {1}",
|
||||||
"{0} - {1} (약 {2}MB)": "{0} - {1} (about {2}MB)",
|
"{0} - {1} (약 {2}MB)": "{0} - {1} (about {2}MB)",
|
||||||
|
"{0} / {1}장": "{0} / {1} images",
|
||||||
"{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2} (change at the top of the main screen)",
|
"{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2} (change at the top of the main screen)",
|
||||||
"{0} → {1} 번역 {2}%": "{0} → {1} translation {2}%",
|
"{0} → {1} 번역 {2}%": "{0} → {1} translation {2}%",
|
||||||
"{0} 로 번역 중 {1}%": "Translating to {0} {1}%",
|
"{0} 로 번역 중 {1}%": "Translating to {0} {1}%",
|
||||||
|
|
@ -384,6 +388,7 @@
|
||||||
"기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "Default: MP4. Audio only is saved as MP3 · M4A",
|
"기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "Default: MP4. Audio only is saved as MP3 · M4A",
|
||||||
"기본값": "Default",
|
"기본값": "Default",
|
||||||
"기본값으로 ({0})": "Default ({0})",
|
"기본값으로 ({0})": "Default ({0})",
|
||||||
|
"기억된 작업: {0} → {1}": "Saved task: {0} → {1}",
|
||||||
"기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "No remembered copies. Copies · moves in the file explorer are added here automatically.",
|
"기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "No remembered copies. Copies · moves in the file explorer are added here automatically.",
|
||||||
"기타 즐겨찾기": "Other bookmarks",
|
"기타 즐겨찾기": "Other bookmarks",
|
||||||
"길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "Long press · right click always opens the action menu (copy · move · delete · rename …). Ctrl + click: select several",
|
"길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "Long press · right click always opens the action menu (copy · move · delete · rename …). Ctrl + click: select several",
|
||||||
|
|
@ -420,6 +425,7 @@
|
||||||
"내려받은 Chrome 엔진 지우기": "Delete the downloaded Chrome engine",
|
"내려받은 Chrome 엔진 지우기": "Delete the downloaded Chrome engine",
|
||||||
"내려받은 rsync 지우기": "Delete downloaded rsync",
|
"내려받은 rsync 지우기": "Delete downloaded rsync",
|
||||||
"내려받은 변환기 파일을 지웁니다. 다시 켜면 다시 내려받습니다.": "Deletes the downloaded converter files. Turning it on again downloads them again.",
|
"내려받은 변환기 파일을 지웁니다. 다시 켜면 다시 내려받습니다.": "Deletes the downloaded converter files. Turning it on again downloads them again.",
|
||||||
|
"내보낸 쿠키 파일을 지웠습니다. 브라우저의 로그인까지 지우려면 웹 브라우저 화면의 [쿠키 · 방문 기록 지우기] 를 누르세요.": "Deleted the exported cookie file. To also remove the browser's logins, tap [Clear cookies and history] in the web browser screen.",
|
||||||
"내보냈습니다: {0}": "Exported: {0}",
|
"내보냈습니다: {0}": "Exported: {0}",
|
||||||
"내역": "History",
|
"내역": "History",
|
||||||
"내역 (최근 폴더)": "History (recent folders)",
|
"내역 (최근 폴더)": "History (recent folders)",
|
||||||
|
|
@ -549,6 +555,7 @@
|
||||||
"동영상 + 자막을 MKV 로 합치기 · AI 자막 · 화면 보정": "Merge video + subtitles into MKV · AI subtitles · picture adjustments",
|
"동영상 + 자막을 MKV 로 합치기 · AI 자막 · 화면 보정": "Merge video + subtitles into MKV · AI subtitles · picture adjustments",
|
||||||
"동영상 {0}개 · 전체 선택": "{0} videos · select all",
|
"동영상 {0}개 · 전체 선택": "{0} videos · select all",
|
||||||
"동영상 {0}개를 MKV 목록에서 뺍니다. 파일은 지우지 않습니다.": "Removes {0} videos from the MKV list. Files are not deleted.",
|
"동영상 {0}개를 MKV 목록에서 뺍니다. 파일은 지우지 않습니다.": "Removes {0} videos from the MKV list. Files are not deleted.",
|
||||||
|
"동영상 목록 · 즐겨찾기 · 파일은 그대로입니다": "The video list, bookmarks and files are kept",
|
||||||
"동영상 목록 · 창 위치 저장": "Save video list · window position",
|
"동영상 목록 · 창 위치 저장": "Save video list · window position",
|
||||||
"동영상 목록에 추가: {0}": "Added to the video list: {0}",
|
"동영상 목록에 추가: {0}": "Added to the video list: {0}",
|
||||||
"동영상 선택": "Choose videos",
|
"동영상 선택": "Choose videos",
|
||||||
|
|
@ -566,8 +573,10 @@
|
||||||
"동영상을 세 번 누르면": "Tapping a video three times",
|
"동영상을 세 번 누르면": "Tapping a video three times",
|
||||||
"동영상이 있는 폴더 아래 jj_mkv (기본)": "jj_mkv under the video's folder (default)",
|
"동영상이 있는 폴더 아래 jj_mkv (기본)": "jj_mkv under the video's folder (default)",
|
||||||
"동작 중 · {0} 까지": "Running · until {0}",
|
"동작 중 · {0} 까지": "Running · until {0}",
|
||||||
|
"되돌릴 수 없습니다.": "This can't be undone.",
|
||||||
"되살리고 다시 시작": "Restore and restart",
|
"되살리고 다시 시작": "Restore and restart",
|
||||||
"되살리기": "Restore",
|
"되살리기": "Restore",
|
||||||
|
"두 번 넣은 비밀번호가 다릅니다": "The two passwords don't match",
|
||||||
"두 쪽 나눠 보기": "Split two-page spreads",
|
"두 쪽 나눠 보기": "Split two-page spreads",
|
||||||
"두 쪽 나눠 보기: 끄기 (누르면 자동)": "Split two-page spreads: off (tap for auto)",
|
"두 쪽 나눠 보기: 끄기 (누르면 자동)": "Split two-page spreads: off (tap for auto)",
|
||||||
"두 쪽 나눠 보기: 자동 - 가로로 긴 그림만 (누르면 켜기)": "Split two-page spreads: auto - wide images only (tap to turn on)",
|
"두 쪽 나눠 보기: 자동 - 가로로 긴 그림만 (누르면 켜기)": "Split two-page spreads: auto - wide images only (tap to turn on)",
|
||||||
|
|
@ -586,9 +595,21 @@
|
||||||
"등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "Registered: \"Play with JJ_MKVMaker\" · \"Make subtitles with JJ_MKVMaker\" (Windows 11: Show more options)",
|
"등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "Registered: \"Play with JJ_MKVMaker\" · \"Make subtitles with JJ_MKVMaker\" (Windows 11: Show more options)",
|
||||||
"라오어": "Lao",
|
"라오어": "Lao",
|
||||||
"러시아어": "Russian",
|
"러시아어": "Russian",
|
||||||
|
"로그인 쿠키는 다운로드 (yt-dlp) 가 쓰도록 이 기기의 앱 폴더에 cookies_youtube.txt 로 저장됩니다 (평문). 이 파일을 가진 사람은 그 계정에 로그인할 수 있으니 기기를 남에게 줄 때는 지우세요. 고른 사이트의 쿠키만 넘깁니다:": "Login cookies are saved as cookies_youtube.txt (plain text) in this device's app folder so downloads (yt-dlp) can use them. Anyone with this file can log in to those accounts, so delete it before giving the device to someone else. Only cookies of the selected sites are passed:",
|
||||||
"로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때": "Opening WebDAV videos that need a login in another app",
|
"로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때": "Opening WebDAV videos that need a login in another app",
|
||||||
"로그인하면 하루 받기 횟수가 늘어납니다": "Signing in raises the daily download limit",
|
"로그인하면 하루 받기 횟수가 늘어납니다": "Signing in raises the daily download limit",
|
||||||
"루마니아어": "Romanian",
|
"루마니아어": "Romanian",
|
||||||
|
"마스터 비밀번호": "Master password",
|
||||||
|
"마스터 비밀번호 넣기": "Enter master password",
|
||||||
|
"마스터 비밀번호 정하기": "Set master password",
|
||||||
|
"마스터 비밀번호가 필요해 기다리는 중": "Waiting for the master password",
|
||||||
|
"마스터 비밀번호로 잠겨 있습니다": "Locked with the master password",
|
||||||
|
"마스터 비밀번호를 끌까요?": "Turn off the master password?",
|
||||||
|
"마스터 비밀번호를 넣어야 저장된 비밀번호를 쓸 수 있습니다": "Enter the master password to use saved passwords",
|
||||||
|
"마스터 비밀번호를 모르면 저장된 비밀번호를 쓸 수 없습니다. 비밀번호가 새어 나가지 않도록, 저장된 WebDAV · OpenSubtitles 비밀번호와 API 키를 지우고 마스터 비밀번호를 초기화합니다. 서버 목록 · 아이디 · 다른 설정은 그대로이며, 비밀번호는 다시 넣어야 합니다.": "Without the master password, saved passwords can't be used. So that they can't leak, the saved WebDAV and OpenSubtitles passwords and API keys are deleted and the master password is reset. The server list, user names and other settings are kept; you'll need to enter the passwords again.",
|
||||||
|
"마스터 비밀번호를 묻는 때": "When to ask for the master password",
|
||||||
|
"마스터 비밀번호를 초기화했습니다": "Reset the master password",
|
||||||
|
"마스터 비밀번호와 같게 정할 수 없습니다": "Can't be the same as the master password",
|
||||||
"마지막": "Last",
|
"마지막": "Last",
|
||||||
"마지막 인코딩 설정을 기억합니다": "Remembers the last encoding settings",
|
"마지막 인코딩 설정을 기억합니다": "Remembers the last encoding settings",
|
||||||
"마지막 장이라 계속 보기를 멈췄습니다.": "Slideshow stopped at the last page.",
|
"마지막 장이라 계속 보기를 멈췄습니다.": "Slideshow stopped at the last page.",
|
||||||
|
|
@ -646,10 +667,12 @@
|
||||||
"무한": "Unlimited",
|
"무한": "Unlimited",
|
||||||
"문서 미리보기": "Document preview",
|
"문서 미리보기": "Document preview",
|
||||||
"문자셋 경고": "Charset warning",
|
"문자셋 경고": "Charset warning",
|
||||||
|
"물어보지 않기": "Never ask",
|
||||||
"미리 볼 수 없습니다: {0}": "Cannot preview: {0}",
|
"미리 볼 수 없습니다: {0}": "Cannot preview: {0}",
|
||||||
"미리보기: 목록에서 동영상을 고르세요": "Preview: choose a video in the list",
|
"미리보기: 목록에서 동영상을 고르세요": "Preview: choose a video in the list",
|
||||||
"미얀마어": "Burmese",
|
"미얀마어": "Burmese",
|
||||||
"미지정": "Unspecified",
|
"미지정": "Unspecified",
|
||||||
|
"바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.": "To change or turn it off, enter the current master password (or the top password).",
|
||||||
"바꾸기": "Change",
|
"바꾸기": "Change",
|
||||||
"바꾸면 바로 저장됩니다": "Changes are saved right away",
|
"바꾸면 바로 저장됩니다": "Changes are saved right away",
|
||||||
"바꾼 것이 없습니다 (원본 그대로)": "Nothing changed (original as is)",
|
"바꾼 것이 없습니다 (원본 그대로)": "Nothing changed (original as is)",
|
||||||
|
|
@ -711,6 +734,7 @@
|
||||||
"보기": "View",
|
"보기": "View",
|
||||||
"보기 {0}초 · Ctrl+휠 확대/축소": "View {0} s · Ctrl+wheel to zoom",
|
"보기 {0}초 · Ctrl+휠 확대/축소": "View {0} s · Ctrl+wheel to zoom",
|
||||||
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "How often [Slideshow] in the viewer turns to the next page",
|
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "How often [Slideshow] in the viewer turns to the next page",
|
||||||
|
"보안": "Security",
|
||||||
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "Choose the buttons to show and drag to reorder.",
|
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "Choose the buttons to show and drag to reorder.",
|
||||||
"보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "Adjustments require re-encoding, so the codec was changed from \"Keep original\" to H.264.",
|
"보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "Adjustments require re-encoding, so the codec was changed from \"Keep original\" to H.264.",
|
||||||
"복사": "Copy",
|
"복사": "Copy",
|
||||||
|
|
@ -747,7 +771,11 @@
|
||||||
"비밀번호 (선택)": "Password (optional)",
|
"비밀번호 (선택)": "Password (optional)",
|
||||||
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "Passwords and the subtitle API key aren't backed up, so please enter them again. Auto Backup runs about once a day, so changes made after it may be missing.",
|
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "Passwords and the subtitle API key aren't backed up, so please enter them again. Auto Backup runs about once a day, so changes made after it may be missing.",
|
||||||
"비밀번호 기억 (안전 저장소)": "Remember passwords (secure storage)",
|
"비밀번호 기억 (안전 저장소)": "Remember passwords (secure storage)",
|
||||||
|
"비밀번호가 저장된 기능을 쓸 때": "When using features with saved passwords",
|
||||||
|
"비밀번호가 틀렸습니다 ({0}번)": "Wrong password ({0} times)",
|
||||||
|
"비밀번호를 넣으세요": "Enter a password",
|
||||||
"비밀번호를 안전 저장소에 저장했습니다": "Password saved to secure storage",
|
"비밀번호를 안전 저장소에 저장했습니다": "Password saved to secure storage",
|
||||||
|
"비밀번호를 잊었습니다": "I forgot the password",
|
||||||
"비어 있는 압축 파일입니다.": "The archive is empty.",
|
"비어 있는 압축 파일입니다.": "The archive is empty.",
|
||||||
"비어 있는 폴더": "Empty folder",
|
"비어 있는 폴더": "Empty folder",
|
||||||
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "This folder is empty.\nPress [Add bookmark] · [New folder] above, or drag bookmarks onto a folder on the left.",
|
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "This folder is empty.\nPress [Add bookmark] · [New folder] above, or drag bookmarks onto a folder on the left.",
|
||||||
|
|
@ -768,10 +796,12 @@
|
||||||
"삭제: {0}": "Deleted: {0}",
|
"삭제: {0}": "Deleted: {0}",
|
||||||
"상위 폴더": "Parent folder",
|
"상위 폴더": "Parent folder",
|
||||||
"상하 반전": "Flip vertically",
|
"상하 반전": "Flip vertically",
|
||||||
|
"새 마스터 비밀번호 정하기": "Set a new master password",
|
||||||
"새 버전 v{0}": "New version v{0}",
|
"새 버전 v{0}": "New version v{0}",
|
||||||
"새 버전 확인: 지금 v{0} · 최신 v{1}": "Update check: current v{0} · latest v{1}",
|
"새 버전 확인: 지금 v{0} · 최신 v{1}": "Update check: current v{0} · latest v{1}",
|
||||||
"새 버전을 확인할 수 없습니다: {0}": "Cannot check for a new version: {0}",
|
"새 버전을 확인할 수 없습니다: {0}": "Cannot check for a new version: {0}",
|
||||||
"새 버전이 예전 기본값을 바꿨으면 켤 때 한 번 무엇이 바뀌었는지 알려 줍니다": "If a new version changed old defaults, shows once at startup what changed",
|
"새 버전이 예전 기본값을 바꿨으면 켤 때 한 번 무엇이 바뀌었는지 알려 줍니다": "If a new version changed old defaults, shows once at startup what changed",
|
||||||
|
"새 비밀번호": "New password",
|
||||||
"새 비밀번호는 아직 저장되지 않았습니다. 앱을 끄면 예전 비밀번호로 돌아갑니다.": "The new password has not been saved yet. If you close the app, it goes back to the old password.",
|
"새 비밀번호는 아직 저장되지 않았습니다. 앱을 끄면 예전 비밀번호로 돌아갑니다.": "The new password has not been saved yet. If you close the app, it goes back to the old password.",
|
||||||
"새 즐겨찾기 추가": "Add new bookmark",
|
"새 즐겨찾기 추가": "Add new bookmark",
|
||||||
"새 창에서": "In a new window",
|
"새 창에서": "In a new window",
|
||||||
|
|
@ -877,6 +907,7 @@
|
||||||
"실시간 동기화 시작": "Start live sync",
|
"실시간 동기화 시작": "Start live sync",
|
||||||
"실시간 동기화 실패: {0} → {1}: {2}": "Live sync failed: {0} → {1}: {2}",
|
"실시간 동기화 실패: {0} → {1}: {2}": "Live sync failed: {0} → {1}: {2}",
|
||||||
"실시간 동기화 추가": "Add live sync",
|
"실시간 동기화 추가": "Add live sync",
|
||||||
|
"실시간 동기화: {0} → {1}": "Live sync: {0} → {1}",
|
||||||
"실시간 동기화: 대상 폴더": "Live sync: target folder",
|
"실시간 동기화: 대상 폴더": "Live sync: target folder",
|
||||||
"실시간 동기화: 원본 폴더": "Live sync: source folder",
|
"실시간 동기화: 원본 폴더": "Live sync: source folder",
|
||||||
"실시간 동기화가 멈췄습니다": "Live sync stopped",
|
"실시간 동기화가 멈췄습니다": "Live sync stopped",
|
||||||
|
|
@ -910,15 +941,20 @@
|
||||||
"아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "Still couldn't save. Please try again in a moment",
|
"아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "Still couldn't save. Please try again in a moment",
|
||||||
"아직 지원하지 않는 플랫폼입니다: {0}": "This platform is not supported yet: {0}",
|
"아직 지원하지 않는 플랫폼입니다: {0}": "This platform is not supported yet: {0}",
|
||||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "Notifications show as JJ_MKVMaker and open the app when clicked (current user only, no admin rights)",
|
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "Notifications show as JJ_MKVMaker and open the app when clicked (current user only, no admin rights)",
|
||||||
|
"암호화 안 됨 (http)": "Not encrypted (http)",
|
||||||
"압축 파일 안에 없습니다": "Not in the archive",
|
"압축 파일 안에 없습니다": "Not in the archive",
|
||||||
"압축 파일 안에 프로그램이 없습니다.": "The program is not in the archive.",
|
"압축 파일 안에 프로그램이 없습니다.": "The program is not in the archive.",
|
||||||
"압축을 풀 수 없습니다: {0}": "Cannot extract the archive: {0}",
|
"압축을 풀 수 없습니다: {0}": "Cannot extract the archive: {0}",
|
||||||
"앞의 rsync 가 끝난 뒤에 하세요.": "Wait until the previous rsync finishes.",
|
"앞의 rsync 가 끝난 뒤에 하세요.": "Wait until the previous rsync finishes.",
|
||||||
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "Wait until the current copy · move finishes.",
|
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "Wait until the current copy · move finishes.",
|
||||||
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "Icon in the app list, home screen and the top-left button in the app. After changing it, the icon on your home screen may disappear and you may need to drag it from the app list again",
|
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "Icon in the app list, home screen and the top-left button in the app. After changing it, the icon on your home screen may disappear and you may need to drag it from the app list again",
|
||||||
|
"앱 설정 전체 (처음 상태로)": "All app settings (back to initial)",
|
||||||
|
"앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "Reset app settings to the initial state (restart the app to apply everything)",
|
||||||
"앱 아이콘": "App icon",
|
"앱 아이콘": "App icon",
|
||||||
"앱 안 브라우저 (권장)": "In-app browser (recommended)",
|
"앱 안 브라우저 (권장)": "In-app browser (recommended)",
|
||||||
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "Use the in-app browser's sign-in (sign in to YouTube on the browser screen)",
|
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "Use the in-app browser's sign-in (sign in to YouTube on the browser screen)",
|
||||||
|
"앱 안 브라우저의 로그인 쿠키: YouTube · Google 에 더해 Instagram · X · 네이버 (치지직) 로그인도 다운로드 (yt-dlp) 에 씁니다. 고르기 · 끄기 · 지우기: 환경 설정 > 다운로드 > YouTube 쿠키": "In-app browser login cookies: besides YouTube and Google, Instagram, X and Naver (Chzzk) logins are now also used for downloads (yt-dlp). To choose, turn off or delete: Settings > Downloads > YouTube cookies",
|
||||||
|
"앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. 사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.": "Deletes the in-app browser's cookies (logins), site data, cache and history, plus the cookie file exported for downloads. You'll need to log in to sites again. Bookmarks are kept.",
|
||||||
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "In-app web browser · bookmarks · video downloads",
|
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "In-app web browser · bookmarks · video downloads",
|
||||||
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "In-app web browser: presses [Skip] for you, and mutes and fast-forwards ads that can't be skipped (\"Skipping ad…\" shows at the top while skipping)",
|
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "In-app web browser: presses [Skip] for you, and mutes and fast-forwards ads that can't be skipped (\"Skipping ad…\" shows at the top while skipping)",
|
||||||
"앱 지우기": "Uninstall app",
|
"앱 지우기": "Uninstall app",
|
||||||
|
|
@ -944,6 +980,7 @@
|
||||||
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "Drop here to add to the Make MKV video list",
|
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "Drop here to add to the Make MKV video list",
|
||||||
"여러 개를 골랐을 때": "When several are selected",
|
"여러 개를 골랐을 때": "When several are selected",
|
||||||
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "How many to process at once when making several videos into MKV (re-encoding uses a lot of CPU)",
|
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "How many to process at once when making several videos into MKV (re-encoding uses a lot of CPU)",
|
||||||
|
"여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "Too many wrong tries. You can try again in {0} s",
|
||||||
"연결 실패: {0}": "Connection failed: {0}",
|
"연결 실패: {0}": "Connection failed: {0}",
|
||||||
"연결 확인": "Test connection",
|
"연결 확인": "Test connection",
|
||||||
"연결됨: 항목 {0}개{1}": "Connected: {0} items{1}",
|
"연결됨: 항목 {0}개{1}": "Connected: {0} items{1}",
|
||||||
|
|
@ -1068,9 +1105,11 @@
|
||||||
"이 WebDAV 서버는 로그인이 필요합니다": "This WebDAV server requires sign-in",
|
"이 WebDAV 서버는 로그인이 필요합니다": "This WebDAV server requires sign-in",
|
||||||
"이 {0}의 설정 파일에 저장됩니다": "Saved in this {0}'s settings file",
|
"이 {0}의 설정 파일에 저장됩니다": "Saved in this {0}'s settings file",
|
||||||
"이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)": "Saved in this {0}'s secure storage (not in the settings file)",
|
"이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)": "Saved in this {0}'s secure storage (not in the settings file)",
|
||||||
|
"이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "Only an irreversible hash is stored on this device, not the password itself.",
|
||||||
"이 기기에서는 AI 번역을 쓸 수 없습니다.": "AI translation is not available on this device.",
|
"이 기기에서는 AI 번역을 쓸 수 없습니다.": "AI translation is not available on this device.",
|
||||||
"이 기기용 파일 없음": "No file for this device",
|
"이 기기용 파일 없음": "No file for this device",
|
||||||
"이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "Couldn't save the password because this device's secure storage is unavailable",
|
"이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "Couldn't save the password because this device's secure storage is unavailable",
|
||||||
|
"이 기기의 폴더": "Folder on this device",
|
||||||
"이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "Cannot register this hotkey (another program may be using it)",
|
"이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "Cannot register this hotkey (another program may be using it)",
|
||||||
"이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.": "These two folders are being synced with rsync right now. Try again when it finishes.",
|
"이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.": "These two folders are being synced with rsync right now. Try again when it finishes.",
|
||||||
"이 버전 건너뛰기": "Skip this version",
|
"이 버전 건너뛰기": "Skip this version",
|
||||||
|
|
@ -1080,6 +1119,8 @@
|
||||||
"이 버전으로 업데이트": "Update to this version",
|
"이 버전으로 업데이트": "Update to this version",
|
||||||
"이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "These options are used the next time you do this copy. Run now with the new options?",
|
"이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "These options are used the next time you do this copy. Run now with the new options?",
|
||||||
"이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "Could not find this browser's bookmarks file.",
|
"이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "Could not find this browser's bookmarks file.",
|
||||||
|
"이 서버를 쓰는 것 {0}개 (지워도 목록에는 남고, 실행하면 서버가 없다고 알립니다):": "{0} items use this server (they stay in their lists; running them reports that the server is missing):",
|
||||||
|
"이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호가 필요합니다. 넣으면 이어서 맞춥니다. 대상 파일은 건드리지 않았습니다.": "The master password is needed to use this server's saved password. Enter it and syncing continues. Target files were not touched.",
|
||||||
"이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "This install has no rsync (downloaded when copying with rsync · or use [Download] on the right)",
|
"이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "This install has no rsync (downloaded when copying with rsync · or use [Download] on the right)",
|
||||||
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "This install has no rsync. Choose one yourself.",
|
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "This install has no rsync. Choose one yourself.",
|
||||||
"이 언어 지우기": "Remove this language",
|
"이 언어 지우기": "Remove this language",
|
||||||
|
|
@ -1091,6 +1132,7 @@
|
||||||
"이 페이지 번역": "Translate this page",
|
"이 페이지 번역": "Translate this page",
|
||||||
"이 페이지 지우기": "Delete this page",
|
"이 페이지 지우기": "Delete this page",
|
||||||
"이 페이지를 {0}(으)로 번역": "Translate this page to {0}",
|
"이 페이지를 {0}(으)로 번역": "Translate this page to {0}",
|
||||||
|
"이 폴더 고르기": "Choose this folder",
|
||||||
"이 폴더 선택": "Choose this folder",
|
"이 폴더 선택": "Choose this folder",
|
||||||
"이 폴더 쌍은 기억한 옵션을 씁니다 (환경 설정이 아니라 모니터링 > 복사 · rsync 에서 고칩니다)": "This folder pair uses its remembered options (edit them in Monitoring > Copy · rsync, not in settings)",
|
"이 폴더 쌍은 기억한 옵션을 씁니다 (환경 설정이 아니라 모니터링 > 복사 · rsync 에서 고칩니다)": "This folder pair uses its remembered options (edit them in Monitoring > Copy · rsync, not in settings)",
|
||||||
"이 폴더 전체": "This whole folder",
|
"이 폴더 전체": "This whole folder",
|
||||||
|
|
@ -1235,8 +1277,14 @@
|
||||||
"저장 안 함": "Don't save",
|
"저장 안 함": "Don't save",
|
||||||
"저장 위치": "Save locations",
|
"저장 위치": "Save locations",
|
||||||
"저장: {0}": "Save: {0}",
|
"저장: {0}": "Save: {0}",
|
||||||
|
"저장된 비밀번호 · API 키를 지웠습니다": "Deleted saved passwords and API keys",
|
||||||
|
"저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다. 잊으면 최상 비밀번호로 초기화하거나, 저장된 비밀번호를 지우고 초기화할 수 있습니다.": "Asked when using saved passwords, settings or features that need a password. If you forget it, reset it with the top password, or delete the saved passwords and reset it.",
|
||||||
|
"저장된 비밀번호 · 설정을 쓸 때 더는 묻지 않습니다.": "It will no longer be asked when using saved passwords or settings.",
|
||||||
"저장된 비밀번호가 없습니다. 한 번만 다시 넣어 주세요": "No saved password. Please enter it once more",
|
"저장된 비밀번호가 없습니다. 한 번만 다시 넣어 주세요": "No saved password. Please enter it once more",
|
||||||
"저장된 비밀번호를 되살리지 못했습니다": "Couldn't restore the saved passwords",
|
"저장된 비밀번호를 되살리지 못했습니다": "Couldn't restore the saved passwords",
|
||||||
|
"저장된 비밀번호를 쓰는 기능 · 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "Enter the master password to use features with saved passwords or open settings. It won't be asked again until the app is closed.",
|
||||||
|
"저장된 비밀번호를 지우고 마스터 초기화": "Delete saved passwords and reset master",
|
||||||
|
"저장된 실제 비밀번호 · API 키": "Saved actual passwords and API keys",
|
||||||
"저장된 옵션으로 실행": "Run with saved options",
|
"저장된 옵션으로 실행": "Run with saved options",
|
||||||
"저장하고 검색": "Save and search",
|
"저장하고 검색": "Save and search",
|
||||||
"저장하고 계속": "Save and continue",
|
"저장하고 계속": "Save and continue",
|
||||||
|
|
@ -1263,10 +1311,16 @@
|
||||||
"정리할 파일을 찾는 중…": "Looking for files to clean up…",
|
"정리할 파일을 찾는 중…": "Looking for files to clean up…",
|
||||||
"정리할 파일이 없었습니다.": "There was nothing to clean up.",
|
"정리할 파일이 없었습니다.": "There was nothing to clean up.",
|
||||||
"정리했습니다: 파일 {0}개 · {1}": "Cleaned up: {0} files · {1}",
|
"정리했습니다: 파일 {0}개 · {1}": "Cleaned up: {0} files · {1}",
|
||||||
|
"정말 초기화할까요?": "Really reset?",
|
||||||
"정보": "Info",
|
"정보": "Info",
|
||||||
"정사각 1:1": "Square 1:1",
|
"정사각 1:1": "Square 1:1",
|
||||||
"정지": "Stop",
|
"정지": "Stop",
|
||||||
|
"정하기": "Set",
|
||||||
|
"정하지 않음. 마스터를 잊으면 저장된 비밀번호를 지우고 마스터를 초기화하는 것만 할 수 있습니다": "Not set. If you forget the master password, you can only delete the saved passwords and reset it",
|
||||||
|
"정하지 않음. 정하면 저장된 비밀번호 (WebDAV · OpenSubtitles · API 키) 와 설정을 쓰기 전에 묻습니다": "Not set. When set, it's asked before using saved passwords (WebDAV, OpenSubtitles, API keys) and settings",
|
||||||
"정하지 않음: MKV 목록에서 [이동] 을 처음 누를 때 고릅니다": "Not set: you choose it the first time you press [Move] in the MKV list",
|
"정하지 않음: MKV 목록에서 [이동] 을 처음 누를 때 고릅니다": "Not set: you choose it the first time you press [Move] in the MKV list",
|
||||||
|
"정해 둠. 마스터 입력 창에 넣으면 초기화 창이 열립니다 (마스터 · 저장된 비밀번호 · 앱 설정 전체 중 고름)": "Set. Entering it in the master password prompt opens the reset window (choose among the master password, saved passwords, all app settings)",
|
||||||
|
"정해 둠. 저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다 (앱을 끌 때까지 한 번)": "Set. Asked when using saved passwords, settings or features that need a password (once until the app is closed)",
|
||||||
"제목": "Title",
|
"제목": "Title",
|
||||||
"조정 전": "Before",
|
"조정 전": "Before",
|
||||||
"종료": "Quit",
|
"종료": "Quit",
|
||||||
|
|
@ -1318,6 +1372,7 @@
|
||||||
"지금": "Current",
|
"지금": "Current",
|
||||||
"지금 {0}%": "Now {0}%",
|
"지금 {0}%": "Now {0}%",
|
||||||
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "The running copy uses the previous options. Stop it and run again with the new options?",
|
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "The running copy uses the previous options. Stop it and run again with the new options?",
|
||||||
|
"지금 마스터 (또는 최상) 비밀번호": "Current master (or top) password",
|
||||||
"지금 맞추기": "Sync now",
|
"지금 맞추기": "Sync now",
|
||||||
"지금 맞추는 중입니다. 끝난 뒤에 하세요.": "Syncing right now. Try again when it finishes.",
|
"지금 맞추는 중입니다. 끝난 뒤에 하세요.": "Syncing right now. Try again when it finishes.",
|
||||||
"지금 버전 (v{0}) 의 설정을 보관했습니다: {1}": "Kept the settings of the current version (v{0}): {1}",
|
"지금 버전 (v{0}) 의 설정을 보관했습니다: {1}": "Kept the settings of the current version (v{0}): {1}",
|
||||||
|
|
@ -1333,6 +1388,7 @@
|
||||||
"지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "This is the Dual App (cloned app). Android may not grant this permission to dual apps. If you can't turn it on, open the original JJ_MKVMaker icon without the badge.",
|
"지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "This is the Dual App (cloned app). Android may not grant this permission to dual apps. If you can't turn it on, open the original JJ_MKVMaker icon without the badge.",
|
||||||
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "The last run did not end normally (last check {0}:{1}). It has been noted in the job log.",
|
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "The last run did not end normally (last check {0}:{1}). It has been noted in the job log.",
|
||||||
"지난 작업 기록": "Old job logs",
|
"지난 작업 기록": "Old job logs",
|
||||||
|
"지우고 초기화": "Delete and reset",
|
||||||
"지우기": "Delete",
|
"지우기": "Delete",
|
||||||
"지우기 포함": "with delete",
|
"지우기 포함": "with delete",
|
||||||
"지우기가 들어 있는 작업입니다": "This task deletes files",
|
"지우기가 들어 있는 작업입니다": "This task deletes files",
|
||||||
|
|
@ -1376,6 +1432,7 @@
|
||||||
"채도": "Saturation",
|
"채도": "Saturation",
|
||||||
"채우기 (가운데 잘라냄)": "Fill (crop the center)",
|
"채우기 (가운데 잘라냄)": "Fill (crop the center)",
|
||||||
"처음 사용 시 내려받기 {0}": "Downloads {0} on first use",
|
"처음 사용 시 내려받기 {0}": "Downloads {0} on first use",
|
||||||
|
"처음 시작 시": "At app start",
|
||||||
"처음 쓸 때 내려받기 (기본)": "Download on first use (default)",
|
"처음 쓸 때 내려받기 (기본)": "Download on first use (default)",
|
||||||
"처음 아이콘 (파랑)": "Original icon (blue)",
|
"처음 아이콘 (파랑)": "Original icon (blue)",
|
||||||
"첫 장입니다.": "This is the first page.",
|
"첫 장입니다.": "This is the first page.",
|
||||||
|
|
@ -1383,7 +1440,16 @@
|
||||||
"청각장애인용": "Hearing impaired",
|
"청각장애인용": "Hearing impaired",
|
||||||
"체코어": "Czech",
|
"체코어": "Czech",
|
||||||
"체크한 동영상 {0}개의 {1}": "{1} of the {0} checked videos",
|
"체크한 동영상 {0}개의 {1}": "{1} of the {0} checked videos",
|
||||||
|
"초기화": "Reset",
|
||||||
|
"초기화할 것을 고르세요.": "Choose what to reset.",
|
||||||
|
"초기화했습니다": "Reset done",
|
||||||
"최고 화질": "Best quality",
|
"최고 화질": "Best quality",
|
||||||
|
"최상 비밀번호": "Top password",
|
||||||
|
"최상 비밀번호 - 초기화": "Top password - reset",
|
||||||
|
"최상 비밀번호 정하기": "Set top password",
|
||||||
|
"최상 비밀번호는 모든 것을 초기화하는 열쇠입니다. 마스터 입력 창에 넣으면 초기화 창이 열립니다.": "The top password is the key that can reset everything. Entering it in the master password prompt opens the reset window.",
|
||||||
|
"최상 비밀번호를 끌까요?": "Turn off the top password?",
|
||||||
|
"최상 비밀번호와 같게 정할 수 없습니다": "Can't be the same as the top password",
|
||||||
"최소화하면 트레이로 (백그라운드 실행)": "Minimize to tray (run in background)",
|
"최소화하면 트레이로 (백그라운드 실행)": "Minimize to tray (run in background)",
|
||||||
"최신": "Latest",
|
"최신": "Latest",
|
||||||
"최신 버전 확인": "Check for updates",
|
"최신 버전 확인": "Check for updates",
|
||||||
|
|
@ -1411,6 +1477,9 @@
|
||||||
"켜면 휴지통으로 보내 되살릴 수 있습니다 (Shift+Delete 는 영구 삭제). 끄면 늘 영구 삭제로 묻습니다. 네트워크 드라이브 · USB 메모리는 휴지통이 없어 늘 영구 삭제입니다": "When on, items go to the Recycle Bin and can be restored (Shift+Delete deletes permanently). When off, it always asks to delete permanently. Network drives and USB memory have no Recycle Bin, so deletion there is always permanent",
|
"켜면 휴지통으로 보내 되살릴 수 있습니다 (Shift+Delete 는 영구 삭제). 끄면 늘 영구 삭제로 묻습니다. 네트워크 드라이브 · USB 메모리는 휴지통이 없어 늘 영구 삭제입니다": "When on, items go to the Recycle Bin and can be restored (Shift+Delete deletes permanently). When off, it always asks to delete permanently. Network drives and USB memory have no Recycle Bin, so deletion there is always permanent",
|
||||||
"켜져 있는 창에서 (기본)": "In the open window (default)",
|
"켜져 있는 창에서 (기본)": "In the open window (default)",
|
||||||
"코덱": "Codec",
|
"코덱": "Codec",
|
||||||
|
"쿠키 · 방문 기록 지우기": "Clear cookies and history",
|
||||||
|
"쿠키 · 방문 기록을 지웠습니다.": "Cleared cookies and history.",
|
||||||
|
"쿠키 파일 지우기": "Delete cookie file",
|
||||||
"크기": "Size",
|
"크기": "Size",
|
||||||
"크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "Size and adjustments are back to original, so the codec was set back to \"Keep original\".",
|
"크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "Size and adjustments are back to original, so the codec was set back to \"Keep original\".",
|
||||||
"크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "Changing the size requires re-encoding, so the codec was changed from \"Keep original\" to H.264.",
|
"크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "Changing the size requires re-encoding, so the codec was changed from \"Keep original\" to H.264.",
|
||||||
|
|
@ -1513,6 +1582,7 @@
|
||||||
"필수 프로그램 점검": "Check required programs",
|
"필수 프로그램 점검": "Check required programs",
|
||||||
"필수 프로그램이 모두 설치되어 있습니다.": "All required programs are installed.",
|
"필수 프로그램이 모두 설치되어 있습니다.": "All required programs are installed.",
|
||||||
"하위 폴더가 없습니다": "No subfolders",
|
"하위 폴더가 없습니다": "No subfolders",
|
||||||
|
"한 번 더": "Once more",
|
||||||
"한 번: 바로 실행": "Single: open right away",
|
"한 번: 바로 실행": "Single: open right away",
|
||||||
"한 번: 바로 실행 (기본)": "Single tap: open (default)",
|
"한 번: 바로 실행 (기본)": "Single tap: open (default)",
|
||||||
"한 번: 보기 · 두 번: 재생 · 세 번: 이동 폴더로 옮기기 (옮기기 전에 묻습니다)": "Once: view · twice: play · three times: move to the move folder (asks before moving)",
|
"한 번: 보기 · 두 번: 재생 · 세 번: 이동 폴더로 옮기기 (옮기기 전에 묻습니다)": "Once: view · twice: play · three times: move to the move folder (asks before moving)",
|
||||||
|
|
@ -1568,6 +1638,7 @@
|
||||||
"확장자별 재생 프로그램": "Player per file extension",
|
"확장자별 재생 프로그램": "Player per file extension",
|
||||||
"환경 설정": "Settings",
|
"환경 설정": "Settings",
|
||||||
"환경 설정 열기": "Open settings",
|
"환경 설정 열기": "Open settings",
|
||||||
|
"환경 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "Enter the master password to open settings. It won't be asked again until the app is closed.",
|
||||||
"회전": "Rotation",
|
"회전": "Rotation",
|
||||||
"회전 없음": "No rotation",
|
"회전 없음": "No rotation",
|
||||||
"회차": "Episode",
|
"회차": "Episode",
|
||||||
|
|
|
||||||
|
|
@ -154,9 +154,11 @@
|
||||||
"SRT 형식이 아니거나 비어 있습니다.": "SRT 形式ではないか、空です。",
|
"SRT 형식이 아니거나 비어 있습니다.": "SRT 形式ではないか、空です。",
|
||||||
"VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "VLC がインストールされていません。インストールするとリストに表示されます(videolan.org)。",
|
"VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "VLC がインストールされていません。インストールするとリストに表示されます(videolan.org)。",
|
||||||
"VLC 로 열기": "VLC で開く",
|
"VLC 로 열기": "VLC で開く",
|
||||||
|
"WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV・OpenSubtitles など。サーバー一覧と ID は残します",
|
||||||
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "WebDAV・OpenSubtitles のパスワードをこの端末の安全な保存領域に保存します。オフにすると保存せず (既存のものも削除)、アプリを起動するたびに入力します",
|
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "WebDAV・OpenSubtitles のパスワードをこの端末の安全な保存領域に保存します。オフにすると保存せず (既存のものも削除)、アプリを起動するたびに入力します",
|
||||||
"WebDAV 서버 \"{0}\" 을(를) 지울까요?": "WebDAV サーバー「{0}」を削除しますか?",
|
"WebDAV 서버 \"{0}\" 을(를) 지울까요?": "WebDAV サーバー「{0}」を削除しますか?",
|
||||||
"WebDAV 서버 고치기": "WebDAV サーバーを編集",
|
"WebDAV 서버 고치기": "WebDAV サーバーを編集",
|
||||||
|
"WebDAV 서버 목록과 그 저장된 비밀번호도 지워집니다. 동영상 목록 · 즐겨찾기 · 파일은 그대로입니다": "WebDAV サーバー一覧とその保存したパスワードも消えます。動画リスト・お気に入り・ファイルはそのままです",
|
||||||
"WebDAV 서버 추가": "WebDAV サーバーを追加",
|
"WebDAV 서버 추가": "WebDAV サーバーを追加",
|
||||||
"WebDAV 에서 받는 중": "WebDAV からダウンロード中",
|
"WebDAV 에서 받는 중": "WebDAV からダウンロード中",
|
||||||
"WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "WebDAV の元に使います (PC のフォルダーは変わるとすぐ同期)。短いほど早く同期しますが、サーバー・バッテリーをより使います。",
|
"WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "WebDAV の元に使います (PC のフォルダーは変わるとすぐ同期)。短いほど早く同期しますが、サーバー・バッテリーをより使います。",
|
||||||
|
|
@ -203,6 +205,7 @@
|
||||||
"cron (분 시 일 월 요일) · 한 줄에 하나": "cron (分 時 日 月 曜日) · 1 行に 1 つ",
|
"cron (분 시 일 월 요일) · 한 줄에 하나": "cron (分 時 日 月 曜日) · 1 行に 1 つ",
|
||||||
"ffmpeg 를 실행할 수 없습니다: {0}": "ffmpeg を実行できません: {0}",
|
"ffmpeg 를 실행할 수 없습니다: {0}": "ffmpeg を実行できません: {0}",
|
||||||
"ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg エラー(コード {0})\n{1}",
|
"ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg エラー(コード {0})\n{1}",
|
||||||
|
"http:// 주소는 아이디 · 비밀번호와 파일이 암호화되지 않고 그대로 갑니다. 같은 네트워크의 다른 사람이 볼 수 있으니 서버가 https 를 지원하면 https:// 로 바꾸세요.": "http:// のアドレスでは ID・パスワードとファイルが暗号化されずに送られます。同じネットワークの他の人が見られるため、サーバーが https に対応していれば https:// に変えてください。",
|
||||||
"lsync (실시간 동기화)": "lsync (リアルタイム同期)",
|
"lsync (실시간 동기화)": "lsync (リアルタイム同期)",
|
||||||
"lsync 로 이동": "lsync へ移動",
|
"lsync 로 이동": "lsync へ移動",
|
||||||
"quit 시작": "終了開始",
|
"quit 시작": "終了開始",
|
||||||
|
|
@ -232,6 +235,7 @@
|
||||||
"{0} (AI 번역 ← {1})": "{0}(AI 翻訳 ← {1})",
|
"{0} (AI 번역 ← {1})": "{0}(AI 翻訳 ← {1})",
|
||||||
"{0} (기본): {1}": "{0}(既定): {1}",
|
"{0} (기본): {1}": "{0}(既定): {1}",
|
||||||
"{0} - {1} (약 {2}MB)": "{0} - {1}(約 {2}MB)",
|
"{0} - {1} (약 {2}MB)": "{0} - {1}(約 {2}MB)",
|
||||||
|
"{0} / {1}장": "{0} / {1} 枚",
|
||||||
"{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(メイン画面の上部で変更)",
|
"{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(メイン画面の上部で変更)",
|
||||||
"{0} → {1} 번역 {2}%": "{0} → {1} 翻訳 {2}%",
|
"{0} → {1} 번역 {2}%": "{0} → {1} 翻訳 {2}%",
|
||||||
"{0} 로 번역 중 {1}%": "{0} に翻訳中 {1}%",
|
"{0} 로 번역 중 {1}%": "{0} に翻訳中 {1}%",
|
||||||
|
|
@ -384,6 +388,7 @@
|
||||||
"기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "既定: MP4。音声のみなら MP3・M4A で保存",
|
"기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "既定: MP4。音声のみなら MP3・M4A で保存",
|
||||||
"기본값": "既定値",
|
"기본값": "既定値",
|
||||||
"기본값으로 ({0})": "既定値に戻す ({0})",
|
"기본값으로 ({0})": "既定値に戻す ({0})",
|
||||||
|
"기억된 작업: {0} → {1}": "記憶した作業: {0} → {1}",
|
||||||
"기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "記憶したコピーはありません。ファイルエクスプローラーでコピー · 移動すると自動で登録されます。",
|
"기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "記憶したコピーはありません。ファイルエクスプローラーでコピー · 移動すると自動で登録されます。",
|
||||||
"기타 즐겨찾기": "その他のブックマーク",
|
"기타 즐겨찾기": "その他のブックマーク",
|
||||||
"길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "長押し · 右クリックは常に機能メニュー (コピー · 移動 · 削除 · 名前の変更 …)。Ctrl + クリック: 複数選択",
|
"길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "長押し · 右クリックは常に機能メニュー (コピー · 移動 · 削除 · 名前の変更 …)。Ctrl + クリック: 複数選択",
|
||||||
|
|
@ -420,6 +425,7 @@
|
||||||
"내려받은 Chrome 엔진 지우기": "ダウンロードした Chrome エンジンを削除",
|
"내려받은 Chrome 엔진 지우기": "ダウンロードした Chrome エンジンを削除",
|
||||||
"내려받은 rsync 지우기": "ダウンロードした rsync を削除",
|
"내려받은 rsync 지우기": "ダウンロードした rsync を削除",
|
||||||
"내려받은 변환기 파일을 지웁니다. 다시 켜면 다시 내려받습니다.": "ダウンロードした変換ツールのファイルを削除します。再びオンにすると再ダウンロードします。",
|
"내려받은 변환기 파일을 지웁니다. 다시 켜면 다시 내려받습니다.": "ダウンロードした変換ツールのファイルを削除します。再びオンにすると再ダウンロードします。",
|
||||||
|
"내보낸 쿠키 파일을 지웠습니다. 브라우저의 로그인까지 지우려면 웹 브라우저 화면의 [쿠키 · 방문 기록 지우기] 를 누르세요.": "書き出した Cookie ファイルを削除しました。ブラウザのログインも消すには、Web ブラウザ画面の [Cookie・閲覧履歴を消去] を押してください。",
|
||||||
"내보냈습니다: {0}": "書き出しました: {0}",
|
"내보냈습니다: {0}": "書き出しました: {0}",
|
||||||
"내역": "履歴",
|
"내역": "履歴",
|
||||||
"내역 (최근 폴더)": "履歴 (最近のフォルダー)",
|
"내역 (최근 폴더)": "履歴 (最近のフォルダー)",
|
||||||
|
|
@ -549,6 +555,7 @@
|
||||||
"동영상 + 자막을 MKV 로 합치기 · AI 자막 · 화면 보정": "動画 + 字幕を MKV に結合・AI 字幕・画面補正",
|
"동영상 + 자막을 MKV 로 합치기 · AI 자막 · 화면 보정": "動画 + 字幕を MKV に結合・AI 字幕・画面補正",
|
||||||
"동영상 {0}개 · 전체 선택": "動画 {0}個・すべて選択",
|
"동영상 {0}개 · 전체 선택": "動画 {0}個・すべて選択",
|
||||||
"동영상 {0}개를 MKV 목록에서 뺍니다. 파일은 지우지 않습니다.": "動画 {0} 本を MKV 一覧から外します。ファイルは削除しません。",
|
"동영상 {0}개를 MKV 목록에서 뺍니다. 파일은 지우지 않습니다.": "動画 {0} 本を MKV 一覧から外します。ファイルは削除しません。",
|
||||||
|
"동영상 목록 · 즐겨찾기 · 파일은 그대로입니다": "動画リスト・お気に入り・ファイルはそのままです",
|
||||||
"동영상 목록 · 창 위치 저장": "動画リスト・ウィンドウ位置を保存",
|
"동영상 목록 · 창 위치 저장": "動画リスト・ウィンドウ位置を保存",
|
||||||
"동영상 목록에 추가: {0}": "動画リストに追加: {0}",
|
"동영상 목록에 추가: {0}": "動画リストに追加: {0}",
|
||||||
"동영상 선택": "動画を選択",
|
"동영상 선택": "動画を選択",
|
||||||
|
|
@ -566,8 +573,10 @@
|
||||||
"동영상을 세 번 누르면": "動画を 3 回タップすると",
|
"동영상을 세 번 누르면": "動画を 3 回タップすると",
|
||||||
"동영상이 있는 폴더 아래 jj_mkv (기본)": "動画があるフォルダーの下の jj_mkv(既定)",
|
"동영상이 있는 폴더 아래 jj_mkv (기본)": "動画があるフォルダーの下の jj_mkv(既定)",
|
||||||
"동작 중 · {0} 까지": "動作中 · {0} まで",
|
"동작 중 · {0} 까지": "動作中 · {0} まで",
|
||||||
|
"되돌릴 수 없습니다.": "元に戻せません。",
|
||||||
"되살리고 다시 시작": "復元して再起動",
|
"되살리고 다시 시작": "復元して再起動",
|
||||||
"되살리기": "元に戻す",
|
"되살리기": "元に戻す",
|
||||||
|
"두 번 넣은 비밀번호가 다릅니다": "2 回入力したパスワードが違います",
|
||||||
"두 쪽 나눠 보기": "見開きを分けて表示",
|
"두 쪽 나눠 보기": "見開きを分けて表示",
|
||||||
"두 쪽 나눠 보기: 끄기 (누르면 자동)": "見開きを分けて表示: オフ (押すと自動)",
|
"두 쪽 나눠 보기: 끄기 (누르면 자동)": "見開きを分けて表示: オフ (押すと自動)",
|
||||||
"두 쪽 나눠 보기: 자동 - 가로로 긴 그림만 (누르면 켜기)": "見開きを分けて表示: 自動 - 横長の画像のみ (押すとオン)",
|
"두 쪽 나눠 보기: 자동 - 가로로 긴 그림만 (누르면 켜기)": "見開きを分けて表示: 自動 - 横長の画像のみ (押すとオン)",
|
||||||
|
|
@ -586,9 +595,21 @@
|
||||||
"등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "登録済み:「JJ_MKVMaker で再生」・「JJ_MKVMaker で字幕を作成」(Windows 11: その他のオプションを表示)",
|
"등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "登録済み:「JJ_MKVMaker で再生」・「JJ_MKVMaker で字幕を作成」(Windows 11: その他のオプションを表示)",
|
||||||
"라오어": "ラオ語",
|
"라오어": "ラオ語",
|
||||||
"러시아어": "ロシア語",
|
"러시아어": "ロシア語",
|
||||||
|
"로그인 쿠키는 다운로드 (yt-dlp) 가 쓰도록 이 기기의 앱 폴더에 cookies_youtube.txt 로 저장됩니다 (평문). 이 파일을 가진 사람은 그 계정에 로그인할 수 있으니 기기를 남에게 줄 때는 지우세요. 고른 사이트의 쿠키만 넘깁니다:": "ログイン Cookie はダウンロード (yt-dlp) が使えるよう、この端末のアプリフォルダーに cookies_youtube.txt (平文) として保存されます。このファイルを持つ人はそのアカウントにログインできるため、端末を人に渡すときは削除してください。選んだサイトの Cookie だけを渡します:",
|
||||||
"로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때": "ログインが必要な WebDAV 動画を他のアプリで開くとき",
|
"로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때": "ログインが必要な WebDAV 動画を他のアプリで開くとき",
|
||||||
"로그인하면 하루 받기 횟수가 늘어납니다": "ログインすると 1日のダウンロード回数が増えます",
|
"로그인하면 하루 받기 횟수가 늘어납니다": "ログインすると 1日のダウンロード回数が増えます",
|
||||||
"루마니아어": "ルーマニア語",
|
"루마니아어": "ルーマニア語",
|
||||||
|
"마스터 비밀번호": "マスターパスワード",
|
||||||
|
"마스터 비밀번호 넣기": "マスターパスワードを入力",
|
||||||
|
"마스터 비밀번호 정하기": "マスターパスワードを設定",
|
||||||
|
"마스터 비밀번호가 필요해 기다리는 중": "マスターパスワードが必要なため待機中",
|
||||||
|
"마스터 비밀번호로 잠겨 있습니다": "マスターパスワードでロックされています",
|
||||||
|
"마스터 비밀번호를 끌까요?": "マスターパスワードをオフにしますか?",
|
||||||
|
"마스터 비밀번호를 넣어야 저장된 비밀번호를 쓸 수 있습니다": "保存したパスワードを使うにはマスターパスワードを入力してください",
|
||||||
|
"마스터 비밀번호를 모르면 저장된 비밀번호를 쓸 수 없습니다. 비밀번호가 새어 나가지 않도록, 저장된 WebDAV · OpenSubtitles 비밀번호와 API 키를 지우고 마스터 비밀번호를 초기화합니다. 서버 목록 · 아이디 · 다른 설정은 그대로이며, 비밀번호는 다시 넣어야 합니다.": "マスターパスワードがわからないと保存したパスワードは使えません。パスワードが漏れないよう、保存した WebDAV・OpenSubtitles のパスワードと API キーを削除してマスターパスワードを初期化します。サーバー一覧・ID・その他の設定はそのままで、パスワードは再入力が必要です。",
|
||||||
|
"마스터 비밀번호를 묻는 때": "マスターパスワードを尋ねるとき",
|
||||||
|
"마스터 비밀번호를 초기화했습니다": "マスターパスワードを初期化しました",
|
||||||
|
"마스터 비밀번호와 같게 정할 수 없습니다": "マスターパスワードと同じにはできません",
|
||||||
"마지막": "最後",
|
"마지막": "最後",
|
||||||
"마지막 인코딩 설정을 기억합니다": "最後のエンコード設定を記憶します",
|
"마지막 인코딩 설정을 기억합니다": "最後のエンコード設定を記憶します",
|
||||||
"마지막 장이라 계속 보기를 멈췄습니다.": "最後のページなので連続表示を停止しました。",
|
"마지막 장이라 계속 보기를 멈췄습니다.": "最後のページなので連続表示を停止しました。",
|
||||||
|
|
@ -646,10 +667,12 @@
|
||||||
"무한": "無制限",
|
"무한": "無制限",
|
||||||
"문서 미리보기": "文書プレビュー",
|
"문서 미리보기": "文書プレビュー",
|
||||||
"문자셋 경고": "文字コードの警告",
|
"문자셋 경고": "文字コードの警告",
|
||||||
|
"물어보지 않기": "尋ねない",
|
||||||
"미리 볼 수 없습니다: {0}": "プレビューできません: {0}",
|
"미리 볼 수 없습니다: {0}": "プレビューできません: {0}",
|
||||||
"미리보기: 목록에서 동영상을 고르세요": "プレビュー: リストで動画を選んでください",
|
"미리보기: 목록에서 동영상을 고르세요": "プレビュー: リストで動画を選んでください",
|
||||||
"미얀마어": "ミャンマー語",
|
"미얀마어": "ミャンマー語",
|
||||||
"미지정": "未指定",
|
"미지정": "未指定",
|
||||||
|
"바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.": "変更・オフにするには現在のマスターパスワード (または最上位パスワード) を入力してください。",
|
||||||
"바꾸기": "変更",
|
"바꾸기": "変更",
|
||||||
"바꾸면 바로 저장됩니다": "変更するとすぐ保存されます",
|
"바꾸면 바로 저장됩니다": "変更するとすぐ保存されます",
|
||||||
"바꾼 것이 없습니다 (원본 그대로)": "変更なし(元のまま)",
|
"바꾼 것이 없습니다 (원본 그대로)": "変更なし(元のまま)",
|
||||||
|
|
@ -711,6 +734,7 @@
|
||||||
"보기": "表示",
|
"보기": "表示",
|
||||||
"보기 {0}초 · Ctrl+휠 확대/축소": "表示 {0}秒・Ctrl+ホイールで拡大/縮小",
|
"보기 {0}초 · Ctrl+휠 확대/축소": "表示 {0}秒・Ctrl+ホイールで拡大/縮小",
|
||||||
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "表示画面の [連続表示] が次のページへ進む間隔",
|
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "表示画面の [連続表示] が次のページへ進む間隔",
|
||||||
|
"보안": "セキュリティ",
|
||||||
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "表示するボタンを選び、ドラッグで順序を変えます。",
|
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "表示するボタンを選び、ドラッグで順序を変えます。",
|
||||||
"보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "補正には再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。",
|
"보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "補正には再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。",
|
||||||
"복사": "コピー",
|
"복사": "コピー",
|
||||||
|
|
@ -747,7 +771,11 @@
|
||||||
"비밀번호 (선택)": "パスワード(任意)",
|
"비밀번호 (선택)": "パスワード(任意)",
|
||||||
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "パスワード・字幕 API キーはバックアップされないので、もう一度入力してください。自動バックアップは 1 日に 1 回程度作成されるため、その後に変更したものは含まれていない場合があります。",
|
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "パスワード・字幕 API キーはバックアップされないので、もう一度入力してください。自動バックアップは 1 日に 1 回程度作成されるため、その後に変更したものは含まれていない場合があります。",
|
||||||
"비밀번호 기억 (안전 저장소)": "パスワードを記憶 (安全な保存領域)",
|
"비밀번호 기억 (안전 저장소)": "パスワードを記憶 (安全な保存領域)",
|
||||||
|
"비밀번호가 저장된 기능을 쓸 때": "パスワードを保存した機能を使うとき",
|
||||||
|
"비밀번호가 틀렸습니다 ({0}번)": "パスワードが違います ({0} 回)",
|
||||||
|
"비밀번호를 넣으세요": "パスワードを入力してください",
|
||||||
"비밀번호를 안전 저장소에 저장했습니다": "パスワードを安全な保存領域に保存しました",
|
"비밀번호를 안전 저장소에 저장했습니다": "パスワードを安全な保存領域に保存しました",
|
||||||
|
"비밀번호를 잊었습니다": "パスワードを忘れました",
|
||||||
"비어 있는 압축 파일입니다.": "空の圧縮ファイルです。",
|
"비어 있는 압축 파일입니다.": "空の圧縮ファイルです。",
|
||||||
"비어 있는 폴더": "空のフォルダー",
|
"비어 있는 폴더": "空のフォルダー",
|
||||||
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。",
|
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。",
|
||||||
|
|
@ -768,10 +796,12 @@
|
||||||
"삭제: {0}": "削除: {0}",
|
"삭제: {0}": "削除: {0}",
|
||||||
"상위 폴더": "上のフォルダー",
|
"상위 폴더": "上のフォルダー",
|
||||||
"상하 반전": "上下反転",
|
"상하 반전": "上下反転",
|
||||||
|
"새 마스터 비밀번호 정하기": "新しいマスターパスワードを設定",
|
||||||
"새 버전 v{0}": "新しいバージョン v{0}",
|
"새 버전 v{0}": "新しいバージョン v{0}",
|
||||||
"새 버전 확인: 지금 v{0} · 최신 v{1}": "新しいバージョンの確認: 現在 v{0} · 最新 v{1}",
|
"새 버전 확인: 지금 v{0} · 최신 v{1}": "新しいバージョンの確認: 現在 v{0} · 最新 v{1}",
|
||||||
"새 버전을 확인할 수 없습니다: {0}": "新しいバージョンを確認できません: {0}",
|
"새 버전을 확인할 수 없습니다: {0}": "新しいバージョンを確認できません: {0}",
|
||||||
"새 버전이 예전 기본값을 바꿨으면 켤 때 한 번 무엇이 바뀌었는지 알려 줍니다": "新しいバージョンが以前の既定値を変更した場合、起動時に一度だけ何が変わったかを知らせます",
|
"새 버전이 예전 기본값을 바꿨으면 켤 때 한 번 무엇이 바뀌었는지 알려 줍니다": "新しいバージョンが以前の既定値を変更した場合、起動時に一度だけ何が変わったかを知らせます",
|
||||||
|
"새 비밀번호": "新しいパスワード",
|
||||||
"새 비밀번호는 아직 저장되지 않았습니다. 앱을 끄면 예전 비밀번호로 돌아갑니다.": "新しいパスワードはまだ保存されていません。アプリを終了すると以前のパスワードに戻ります。",
|
"새 비밀번호는 아직 저장되지 않았습니다. 앱을 끄면 예전 비밀번호로 돌아갑니다.": "新しいパスワードはまだ保存されていません。アプリを終了すると以前のパスワードに戻ります。",
|
||||||
"새 즐겨찾기 추가": "新しいブックマークを追加",
|
"새 즐겨찾기 추가": "新しいブックマークを追加",
|
||||||
"새 창에서": "新しいウィンドウで",
|
"새 창에서": "新しいウィンドウで",
|
||||||
|
|
@ -877,6 +907,7 @@
|
||||||
"실시간 동기화 시작": "リアルタイム同期の開始",
|
"실시간 동기화 시작": "リアルタイム同期の開始",
|
||||||
"실시간 동기화 실패: {0} → {1}: {2}": "リアルタイム同期に失敗: {0} → {1}: {2}",
|
"실시간 동기화 실패: {0} → {1}: {2}": "リアルタイム同期に失敗: {0} → {1}: {2}",
|
||||||
"실시간 동기화 추가": "リアルタイム同期を追加",
|
"실시간 동기화 추가": "リアルタイム同期を追加",
|
||||||
|
"실시간 동기화: {0} → {1}": "リアルタイム同期: {0} → {1}",
|
||||||
"실시간 동기화: 대상 폴더": "リアルタイム同期: 対象フォルダー",
|
"실시간 동기화: 대상 폴더": "リアルタイム同期: 対象フォルダー",
|
||||||
"실시간 동기화: 원본 폴더": "リアルタイム同期: 元フォルダー",
|
"실시간 동기화: 원본 폴더": "リアルタイム同期: 元フォルダー",
|
||||||
"실시간 동기화가 멈췄습니다": "リアルタイム同期が停止しました",
|
"실시간 동기화가 멈췄습니다": "リアルタイム同期が停止しました",
|
||||||
|
|
@ -910,15 +941,20 @@
|
||||||
"아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "まだ保存できませんでした。しばらくしてからもう一度お試しください",
|
"아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "まだ保存できませんでした。しばらくしてからもう一度お試しください",
|
||||||
"아직 지원하지 않는 플랫폼입니다: {0}": "まだ対応していないプラットフォームです: {0}",
|
"아직 지원하지 않는 플랫폼입니다: {0}": "まだ対応していないプラットフォームです: {0}",
|
||||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)",
|
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)",
|
||||||
|
"암호화 안 됨 (http)": "暗号化なし (http)",
|
||||||
"압축 파일 안에 없습니다": "圧縮ファイル内にありません",
|
"압축 파일 안에 없습니다": "圧縮ファイル内にありません",
|
||||||
"압축 파일 안에 프로그램이 없습니다.": "圧縮ファイル内にプログラムがありません。",
|
"압축 파일 안에 프로그램이 없습니다.": "圧縮ファイル内にプログラムがありません。",
|
||||||
"압축을 풀 수 없습니다: {0}": "圧縮ファイルを展開できません: {0}",
|
"압축을 풀 수 없습니다: {0}": "圧縮ファイルを展開できません: {0}",
|
||||||
"앞의 rsync 가 끝난 뒤에 하세요.": "前の rsync が終わってから行ってください。",
|
"앞의 rsync 가 끝난 뒤에 하세요.": "前の rsync が終わってから行ってください。",
|
||||||
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "前のコピー · 移動が終わってから行ってください。",
|
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "前のコピー · 移動が終わってから行ってください。",
|
||||||
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "アプリ一覧・ホーム画面とアプリ内左上のボタンのアイコンです。変更するとホーム画面のアイコンが消えることがあり、アプリ一覧から再度ドラッグする必要がある場合があります",
|
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "アプリ一覧・ホーム画面とアプリ内左上のボタンのアイコンです。変更するとホーム画面のアイコンが消えることがあり、アプリ一覧から再度ドラッグする必要がある場合があります",
|
||||||
|
"앱 설정 전체 (처음 상태로)": "アプリの設定すべて (初期状態に)",
|
||||||
|
"앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "アプリの設定を初期状態に戻しました (すべて反映するにはアプリを再起動してください)",
|
||||||
"앱 아이콘": "アプリアイコン",
|
"앱 아이콘": "アプリアイコン",
|
||||||
"앱 안 브라우저 (권장)": "アプリ内ブラウザー(推奨)",
|
"앱 안 브라우저 (권장)": "アプリ内ブラウザー(推奨)",
|
||||||
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "アプリ内ブラウザーのログインを使う(ブラウザー画面で YouTube にログインしてください)",
|
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "アプリ内ブラウザーのログインを使う(ブラウザー画面で YouTube にログインしてください)",
|
||||||
|
"앱 안 브라우저의 로그인 쿠키: YouTube · Google 에 더해 Instagram · X · 네이버 (치지직) 로그인도 다운로드 (yt-dlp) 에 씁니다. 고르기 · 끄기 · 지우기: 환경 설정 > 다운로드 > YouTube 쿠키": "アプリ内ブラウザのログイン Cookie: YouTube・Google に加えて Instagram・X・NAVER (CHZZK) のログインもダウンロード (yt-dlp) に使います。選択・オフ・削除: 環境設定 > ダウンロード > YouTube Cookie",
|
||||||
|
"앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. 사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.": "アプリ内ブラウザの Cookie (ログイン)・サイトデータ・キャッシュ・閲覧履歴と、ダウンロード用に書き出した Cookie ファイルを削除します。サイトへの再ログインが必要です。お気に入りはそのままです。",
|
||||||
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "アプリ内 Web ブラウザー・ブックマーク・動画ダウンロード",
|
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "アプリ内 Web ブラウザー・ブックマーク・動画ダウンロード",
|
||||||
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)",
|
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)",
|
||||||
"앱 지우기": "アプリを削除",
|
"앱 지우기": "アプリを削除",
|
||||||
|
|
@ -944,6 +980,7 @@
|
||||||
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "ここにドロップすると MKV 作成の動画リストに追加します",
|
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "ここにドロップすると MKV 作成の動画リストに追加します",
|
||||||
"여러 개를 골랐을 때": "複数選んだとき",
|
"여러 개를 골랐을 때": "複数選んだとき",
|
||||||
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "複数の動画を MKV にするとき同時に処理する数(再エンコードは CPU を多く使います)",
|
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "複数の動画を MKV にするとき同時に処理する数(再エンコードは CPU を多く使います)",
|
||||||
|
"여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "何度も間違えたため {0} 秒後に再入力できます",
|
||||||
"연결 실패: {0}": "接続失敗: {0}",
|
"연결 실패: {0}": "接続失敗: {0}",
|
||||||
"연결 확인": "接続テスト",
|
"연결 확인": "接続テスト",
|
||||||
"연결됨: 항목 {0}개{1}": "接続しました: 項目 {0} 個{1}",
|
"연결됨: 항목 {0}개{1}": "接続しました: 項目 {0} 個{1}",
|
||||||
|
|
@ -1068,9 +1105,11 @@
|
||||||
"이 WebDAV 서버는 로그인이 필요합니다": "この WebDAV サーバーはログインが必要です",
|
"이 WebDAV 서버는 로그인이 필요합니다": "この WebDAV サーバーはログインが必要です",
|
||||||
"이 {0}의 설정 파일에 저장됩니다": "この{0}の設定ファイルに保存されます",
|
"이 {0}의 설정 파일에 저장됩니다": "この{0}の設定ファイルに保存されます",
|
||||||
"이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)": "この {0} の安全な保存領域に保存されます (設定ファイルには書きません)",
|
"이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)": "この {0} の安全な保存領域に保存されます (設定ファイルには書きません)",
|
||||||
|
"이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "この端末にはパスワード自体ではなく、元に戻せないハッシュだけが残ります。",
|
||||||
"이 기기에서는 AI 번역을 쓸 수 없습니다.": "この端末では AI 翻訳を使えません。",
|
"이 기기에서는 AI 번역을 쓸 수 없습니다.": "この端末では AI 翻訳を使えません。",
|
||||||
"이 기기용 파일 없음": "このデバイス用のファイルなし",
|
"이 기기용 파일 없음": "このデバイス用のファイルなし",
|
||||||
"이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "この端末の安全な保存領域を使えないため、パスワードを保存できませんでした",
|
"이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "この端末の安全な保存領域を使えないため、パスワードを保存できませんでした",
|
||||||
|
"이 기기의 폴더": "この端末のフォルダー",
|
||||||
"이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "このショートカットを登録できません(他のプログラムが使用中の可能性があります)",
|
"이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "このショートカットを登録できません(他のプログラムが使用中の可能性があります)",
|
||||||
"이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.": "この 2 つのフォルダーは今 rsync 中です。終わってから行ってください。",
|
"이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.": "この 2 つのフォルダーは今 rsync 中です。終わってから行ってください。",
|
||||||
"이 버전 건너뛰기": "このバージョンをスキップ",
|
"이 버전 건너뛰기": "このバージョンをスキップ",
|
||||||
|
|
@ -1080,6 +1119,8 @@
|
||||||
"이 버전으로 업데이트": "このバージョンに更新",
|
"이 버전으로 업데이트": "このバージョンに更新",
|
||||||
"이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "このコピーを次に行うときこのオプションを使います。今、新しいオプションで実行しますか?",
|
"이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "このコピーを次に行うときこのオプションを使います。今、新しいオプションで実行しますか?",
|
||||||
"이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "このブラウザーのブックマークファイルが見つかりませんでした。",
|
"이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "このブラウザーのブックマークファイルが見つかりませんでした。",
|
||||||
|
"이 서버를 쓰는 것 {0}개 (지워도 목록에는 남고, 실행하면 서버가 없다고 알립니다):": "このサーバーを使うもの {0} 件 (削除しても一覧には残り、実行するとサーバーがないと通知します):",
|
||||||
|
"이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호가 필요합니다. 넣으면 이어서 맞춥니다. 대상 파일은 건드리지 않았습니다.": "このサーバーの保存したパスワードを使うにはマスターパスワードが必要です。入力すると同期を続けます。対象のファイルには触れていません。",
|
||||||
"이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "このインストールには rsync が含まれていません (rsync でコピーするとき · 右の [ダウンロード] で取得します)",
|
"이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "このインストールには rsync が含まれていません (rsync でコピーするとき · 右の [ダウンロード] で取得します)",
|
||||||
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "このインストールには rsync が含まれていません。直接指定してください。",
|
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "このインストールには rsync が含まれていません。直接指定してください。",
|
||||||
"이 언어 지우기": "この言語を削除",
|
"이 언어 지우기": "この言語を削除",
|
||||||
|
|
@ -1091,6 +1132,7 @@
|
||||||
"이 페이지 번역": "このページを翻訳",
|
"이 페이지 번역": "このページを翻訳",
|
||||||
"이 페이지 지우기": "このページを削除",
|
"이 페이지 지우기": "このページを削除",
|
||||||
"이 페이지를 {0}(으)로 번역": "このページを{0}に翻訳",
|
"이 페이지를 {0}(으)로 번역": "このページを{0}に翻訳",
|
||||||
|
"이 폴더 고르기": "このフォルダーを選択",
|
||||||
"이 폴더 선택": "このフォルダーを選択",
|
"이 폴더 선택": "このフォルダーを選択",
|
||||||
"이 폴더 쌍은 기억한 옵션을 씁니다 (환경 설정이 아니라 모니터링 > 복사 · rsync 에서 고칩니다)": "このフォルダーの組は記憶したオプションを使います (環境設定ではなくモニタリング > コピー・rsync で変更します)",
|
"이 폴더 쌍은 기억한 옵션을 씁니다 (환경 설정이 아니라 모니터링 > 복사 · rsync 에서 고칩니다)": "このフォルダーの組は記憶したオプションを使います (環境設定ではなくモニタリング > コピー・rsync で変更します)",
|
||||||
"이 폴더 전체": "このフォルダー全体",
|
"이 폴더 전체": "このフォルダー全体",
|
||||||
|
|
@ -1235,8 +1277,14 @@
|
||||||
"저장 안 함": "保存しない",
|
"저장 안 함": "保存しない",
|
||||||
"저장 위치": "保存先",
|
"저장 위치": "保存先",
|
||||||
"저장: {0}": "保存: {0}",
|
"저장: {0}": "保存: {0}",
|
||||||
|
"저장된 비밀번호 · API 키를 지웠습니다": "保存したパスワード・API キーを削除しました",
|
||||||
|
"저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다. 잊으면 최상 비밀번호로 초기화하거나, 저장된 비밀번호를 지우고 초기화할 수 있습니다.": "保存したパスワード・設定・パスワードが必要な機能を使うときに尋ねます。忘れたら最上位パスワードで初期化するか、保存したパスワードを削除して初期化できます。",
|
||||||
|
"저장된 비밀번호 · 설정을 쓸 때 더는 묻지 않습니다.": "保存したパスワード・設定を使うときにもう尋ねません。",
|
||||||
"저장된 비밀번호가 없습니다. 한 번만 다시 넣어 주세요": "保存されたパスワードがありません。もう一度だけ入力してください",
|
"저장된 비밀번호가 없습니다. 한 번만 다시 넣어 주세요": "保存されたパスワードがありません。もう一度だけ入力してください",
|
||||||
"저장된 비밀번호를 되살리지 못했습니다": "保存されたパスワードを復元できませんでした",
|
"저장된 비밀번호를 되살리지 못했습니다": "保存されたパスワードを復元できませんでした",
|
||||||
|
"저장된 비밀번호를 쓰는 기능 · 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "保存したパスワードを使う機能・設定を開くにはマスターパスワードを入力してください。アプリを終了するまで再度尋ねません。",
|
||||||
|
"저장된 비밀번호를 지우고 마스터 초기화": "保存したパスワードを削除してマスターを初期化",
|
||||||
|
"저장된 실제 비밀번호 · API 키": "保存した実際のパスワード・API キー",
|
||||||
"저장된 옵션으로 실행": "保存済みのオプションで実行",
|
"저장된 옵션으로 실행": "保存済みのオプションで実行",
|
||||||
"저장하고 검색": "保存して検索",
|
"저장하고 검색": "保存して検索",
|
||||||
"저장하고 계속": "保存して続行",
|
"저장하고 계속": "保存して続行",
|
||||||
|
|
@ -1263,10 +1311,16 @@
|
||||||
"정리할 파일을 찾는 중…": "整理するファイルを探しています…",
|
"정리할 파일을 찾는 중…": "整理するファイルを探しています…",
|
||||||
"정리할 파일이 없었습니다.": "整理するファイルはありませんでした。",
|
"정리할 파일이 없었습니다.": "整理するファイルはありませんでした。",
|
||||||
"정리했습니다: 파일 {0}개 · {1}": "整理しました: ファイル {0}個・{1}",
|
"정리했습니다: 파일 {0}개 · {1}": "整理しました: ファイル {0}個・{1}",
|
||||||
|
"정말 초기화할까요?": "本当に初期化しますか?",
|
||||||
"정보": "情報",
|
"정보": "情報",
|
||||||
"정사각 1:1": "正方形 1:1",
|
"정사각 1:1": "正方形 1:1",
|
||||||
"정지": "停止",
|
"정지": "停止",
|
||||||
|
"정하기": "設定",
|
||||||
|
"정하지 않음. 마스터를 잊으면 저장된 비밀번호를 지우고 마스터를 초기화하는 것만 할 수 있습니다": "未設定。マスターを忘れたら、保存したパスワードを削除してマスターを初期化することしかできません",
|
||||||
|
"정하지 않음. 정하면 저장된 비밀번호 (WebDAV · OpenSubtitles · API 키) 와 설정을 쓰기 전에 묻습니다": "未設定。設定すると、保存したパスワード (WebDAV・OpenSubtitles・API キー) と設定を使う前に尋ねます",
|
||||||
"정하지 않음: MKV 목록에서 [이동] 을 처음 누를 때 고릅니다": "未設定: MKV リストで[移動]を初めて押したときに選びます",
|
"정하지 않음: MKV 목록에서 [이동] 을 처음 누를 때 고릅니다": "未設定: MKV リストで[移動]を初めて押したときに選びます",
|
||||||
|
"정해 둠. 마스터 입력 창에 넣으면 초기화 창이 열립니다 (마스터 · 저장된 비밀번호 · 앱 설정 전체 중 고름)": "設定済み。マスター入力欄に入れると初期化ウィンドウが開きます (マスター・保存したパスワード・アプリの設定すべてから選択)",
|
||||||
|
"정해 둠. 저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다 (앱을 끌 때까지 한 번)": "設定済み。保存したパスワード・設定・パスワードが必要な機能を使うときに尋ねます (アプリを終了するまで 1 回)",
|
||||||
"제목": "タイトル",
|
"제목": "タイトル",
|
||||||
"조정 전": "調整前",
|
"조정 전": "調整前",
|
||||||
"종료": "終了",
|
"종료": "終了",
|
||||||
|
|
@ -1318,6 +1372,7 @@
|
||||||
"지금": "現在",
|
"지금": "現在",
|
||||||
"지금 {0}%": "現在 {0}%",
|
"지금 {0}%": "現在 {0}%",
|
||||||
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "実行中のコピーは前のオプションで動いています。止めて新しいオプションで再実行しますか?",
|
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "実行中のコピーは前のオプションで動いています。止めて新しいオプションで再実行しますか?",
|
||||||
|
"지금 마스터 (또는 최상) 비밀번호": "現在のマスター (または最上位) パスワード",
|
||||||
"지금 맞추기": "今すぐ同期",
|
"지금 맞추기": "今すぐ同期",
|
||||||
"지금 맞추는 중입니다. 끝난 뒤에 하세요.": "同期中です。終わってから行ってください。",
|
"지금 맞추는 중입니다. 끝난 뒤에 하세요.": "同期中です。終わってから行ってください。",
|
||||||
"지금 버전 (v{0}) 의 설정을 보관했습니다: {1}": "現在のバージョン (v{0}) の設定を保管しました: {1}",
|
"지금 버전 (v{0}) 의 설정을 보관했습니다: {1}": "現在のバージョン (v{0}) の設定を保管しました: {1}",
|
||||||
|
|
@ -1333,6 +1388,7 @@
|
||||||
"지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "これはデュアルアプリ (複製したアプリ) です。Android はデュアルアプリにこの権限を与えない場合があります。許可画面でオンにできない場合は、バッジのない元の JJ_MKVMaker アイコンから開いてください。",
|
"지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "これはデュアルアプリ (複製したアプリ) です。Android はデュアルアプリにこの権限を与えない場合があります。許可画面でオンにできない場合は、バッジのない元の JJ_MKVMaker アイコンから開いてください。",
|
||||||
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "前回の実行が正常に終了しませんでした(最終確認 {0}:{1})。作業記録に残しました。",
|
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "前回の実行が正常に終了しませんでした(最終確認 {0}:{1})。作業記録に残しました。",
|
||||||
"지난 작업 기록": "過去の作業記録",
|
"지난 작업 기록": "過去の作業記録",
|
||||||
|
"지우고 초기화": "削除して初期化",
|
||||||
"지우기": "削除",
|
"지우기": "削除",
|
||||||
"지우기 포함": "削除あり",
|
"지우기 포함": "削除あり",
|
||||||
"지우기가 들어 있는 작업입니다": "削除を含む作業です",
|
"지우기가 들어 있는 작업입니다": "削除を含む作業です",
|
||||||
|
|
@ -1376,6 +1432,7 @@
|
||||||
"채도": "彩度",
|
"채도": "彩度",
|
||||||
"채우기 (가운데 잘라냄)": "フィル(中央を切り抜き)",
|
"채우기 (가운데 잘라냄)": "フィル(中央を切り抜き)",
|
||||||
"처음 사용 시 내려받기 {0}": "初回使用時にダウンロード {0}",
|
"처음 사용 시 내려받기 {0}": "初回使用時にダウンロード {0}",
|
||||||
|
"처음 시작 시": "起動時",
|
||||||
"처음 쓸 때 내려받기 (기본)": "初めて使うときにダウンロード (既定)",
|
"처음 쓸 때 내려받기 (기본)": "初めて使うときにダウンロード (既定)",
|
||||||
"처음 아이콘 (파랑)": "最初のアイコン(青)",
|
"처음 아이콘 (파랑)": "最初のアイコン(青)",
|
||||||
"첫 장입니다.": "最初のページです。",
|
"첫 장입니다.": "最初のページです。",
|
||||||
|
|
@ -1383,7 +1440,16 @@
|
||||||
"청각장애인용": "聴覚障害者用",
|
"청각장애인용": "聴覚障害者用",
|
||||||
"체코어": "チェコ語",
|
"체코어": "チェコ語",
|
||||||
"체크한 동영상 {0}개의 {1}": "チェックした動画 {0}個の{1}",
|
"체크한 동영상 {0}개의 {1}": "チェックした動画 {0}個の{1}",
|
||||||
|
"초기화": "初期化",
|
||||||
|
"초기화할 것을 고르세요.": "初期化するものを選んでください。",
|
||||||
|
"초기화했습니다": "初期化しました",
|
||||||
"최고 화질": "最高画質",
|
"최고 화질": "最高画質",
|
||||||
|
"최상 비밀번호": "最上位パスワード",
|
||||||
|
"최상 비밀번호 - 초기화": "最上位パスワード - 初期化",
|
||||||
|
"최상 비밀번호 정하기": "最上位パスワードを設定",
|
||||||
|
"최상 비밀번호는 모든 것을 초기화하는 열쇠입니다. 마스터 입력 창에 넣으면 초기화 창이 열립니다.": "最上位パスワードはすべてを初期化できる鍵です。マスター入力欄に入れると初期化ウィンドウが開きます。",
|
||||||
|
"최상 비밀번호를 끌까요?": "最上位パスワードをオフにしますか?",
|
||||||
|
"최상 비밀번호와 같게 정할 수 없습니다": "最上位パスワードと同じにはできません",
|
||||||
"최소화하면 트레이로 (백그라운드 실행)": "最小化するとトレイへ(バックグラウンド実行)",
|
"최소화하면 트레이로 (백그라운드 실행)": "最小化するとトレイへ(バックグラウンド実行)",
|
||||||
"최신": "最新",
|
"최신": "最新",
|
||||||
"최신 버전 확인": "最新バージョンを確認",
|
"최신 버전 확인": "最新バージョンを確認",
|
||||||
|
|
@ -1411,6 +1477,9 @@
|
||||||
"켜면 휴지통으로 보내 되살릴 수 있습니다 (Shift+Delete 는 영구 삭제). 끄면 늘 영구 삭제로 묻습니다. 네트워크 드라이브 · USB 메모리는 휴지통이 없어 늘 영구 삭제입니다": "オンにするとごみ箱に移動し、元に戻せます (Shift+Delete は完全に削除)。オフにすると常に完全に削除として確認します。ネットワークドライブ・USB メモリにはごみ箱がないため常に完全に削除です",
|
"켜면 휴지통으로 보내 되살릴 수 있습니다 (Shift+Delete 는 영구 삭제). 끄면 늘 영구 삭제로 묻습니다. 네트워크 드라이브 · USB 메모리는 휴지통이 없어 늘 영구 삭제입니다": "オンにするとごみ箱に移動し、元に戻せます (Shift+Delete は完全に削除)。オフにすると常に完全に削除として確認します。ネットワークドライブ・USB メモリにはごみ箱がないため常に完全に削除です",
|
||||||
"켜져 있는 창에서 (기본)": "開いているウィンドウで(既定)",
|
"켜져 있는 창에서 (기본)": "開いているウィンドウで(既定)",
|
||||||
"코덱": "コーデック",
|
"코덱": "コーデック",
|
||||||
|
"쿠키 · 방문 기록 지우기": "Cookie・閲覧履歴を消去",
|
||||||
|
"쿠키 · 방문 기록을 지웠습니다.": "Cookie・閲覧履歴を消去しました。",
|
||||||
|
"쿠키 파일 지우기": "Cookie ファイルを削除",
|
||||||
"크기": "サイズ",
|
"크기": "サイズ",
|
||||||
"크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "サイズ・補正を元に戻したので、コーデックも「元のまま」に戻しました。",
|
"크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "サイズ・補正を元に戻したので、コーデックも「元のまま」に戻しました。",
|
||||||
"크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "サイズを変えると再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。",
|
"크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "サイズを変えると再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。",
|
||||||
|
|
@ -1513,6 +1582,7 @@
|
||||||
"필수 프로그램 점검": "必須プログラムの点検",
|
"필수 프로그램 점검": "必須プログラムの点検",
|
||||||
"필수 프로그램이 모두 설치되어 있습니다.": "必須プログラムはすべてインストールされています。",
|
"필수 프로그램이 모두 설치되어 있습니다.": "必須プログラムはすべてインストールされています。",
|
||||||
"하위 폴더가 없습니다": "サブフォルダーがありません",
|
"하위 폴더가 없습니다": "サブフォルダーがありません",
|
||||||
|
"한 번 더": "もう一度",
|
||||||
"한 번: 바로 실행": "1 回: すぐに開く",
|
"한 번: 바로 실행": "1 回: すぐに開く",
|
||||||
"한 번: 바로 실행 (기본)": "1 回: すぐに開く (既定)",
|
"한 번: 바로 실행 (기본)": "1 回: すぐに開く (既定)",
|
||||||
"한 번: 보기 · 두 번: 재생 · 세 번: 이동 폴더로 옮기기 (옮기기 전에 묻습니다)": "1 回: 表示・2 回: 再生・3 回: 移動フォルダーへ移動 (移動前に確認)",
|
"한 번: 보기 · 두 번: 재생 · 세 번: 이동 폴더로 옮기기 (옮기기 전에 묻습니다)": "1 回: 表示・2 回: 再生・3 回: 移動フォルダーへ移動 (移動前に確認)",
|
||||||
|
|
@ -1568,6 +1638,7 @@
|
||||||
"확장자별 재생 프로그램": "拡張子ごとの再生プログラム",
|
"확장자별 재생 프로그램": "拡張子ごとの再生プログラム",
|
||||||
"환경 설정": "環境設定",
|
"환경 설정": "環境設定",
|
||||||
"환경 설정 열기": "環境設定を開く",
|
"환경 설정 열기": "環境設定を開く",
|
||||||
|
"환경 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "環境設定を開くにはマスターパスワードを入力してください。アプリを終了するまで再度尋ねません。",
|
||||||
"회전": "回転",
|
"회전": "回転",
|
||||||
"회전 없음": "回転なし",
|
"회전 없음": "回転なし",
|
||||||
"회차": "話数",
|
"회차": "話数",
|
||||||
|
|
|
||||||
|
|
@ -154,9 +154,11 @@
|
||||||
"SRT 형식이 아니거나 비어 있습니다.": "不是 SRT 格式或为空。",
|
"SRT 형식이 아니거나 비어 있습니다.": "不是 SRT 格式或为空。",
|
||||||
"VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "未安装 VLC。安装后会出现在列表中(videolan.org)。",
|
"VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "未安装 VLC。安装后会出现在列表中(videolan.org)。",
|
||||||
"VLC 로 열기": "用 VLC 打开",
|
"VLC 로 열기": "用 VLC 打开",
|
||||||
|
"WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV · OpenSubtitles 等。保留服务器列表和用户名",
|
||||||
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "将 WebDAV · OpenSubtitles 密码保存在本设备的安全存储中。关闭后不保存 (已有的也会删除),每次启动应用时重新输入",
|
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "将 WebDAV · OpenSubtitles 密码保存在本设备的安全存储中。关闭后不保存 (已有的也会删除),每次启动应用时重新输入",
|
||||||
"WebDAV 서버 \"{0}\" 을(를) 지울까요?": "删除 WebDAV 服务器“{0}”吗?",
|
"WebDAV 서버 \"{0}\" 을(를) 지울까요?": "删除 WebDAV 服务器“{0}”吗?",
|
||||||
"WebDAV 서버 고치기": "编辑 WebDAV 服务器",
|
"WebDAV 서버 고치기": "编辑 WebDAV 服务器",
|
||||||
|
"WebDAV 서버 목록과 그 저장된 비밀번호도 지워집니다. 동영상 목록 · 즐겨찾기 · 파일은 그대로입니다": "WebDAV 服务器列表及其保存的密码也会被删除。视频列表 · 收藏 · 文件保持不变",
|
||||||
"WebDAV 서버 추가": "添加 WebDAV 服务器",
|
"WebDAV 서버 추가": "添加 WebDAV 服务器",
|
||||||
"WebDAV 에서 받는 중": "正在从 WebDAV 下载",
|
"WebDAV 에서 받는 중": "正在从 WebDAV 下载",
|
||||||
"WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "用于 WebDAV 源 (电脑上的文件夹一有更改就同步)。间隔越短同步越快,但会更多占用服务器和电池。",
|
"WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.": "用于 WebDAV 源 (电脑上的文件夹一有更改就同步)。间隔越短同步越快,但会更多占用服务器和电池。",
|
||||||
|
|
@ -203,6 +205,7 @@
|
||||||
"cron (분 시 일 월 요일) · 한 줄에 하나": "cron (分 时 日 月 星期) · 每行一个",
|
"cron (분 시 일 월 요일) · 한 줄에 하나": "cron (分 时 日 月 星期) · 每行一个",
|
||||||
"ffmpeg 를 실행할 수 없습니다: {0}": "无法运行 ffmpeg: {0}",
|
"ffmpeg 를 실행할 수 없습니다: {0}": "无法运行 ffmpeg: {0}",
|
||||||
"ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg 错误(代码 {0})\n{1}",
|
"ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg 错误(代码 {0})\n{1}",
|
||||||
|
"http:// 주소는 아이디 · 비밀번호와 파일이 암호화되지 않고 그대로 갑니다. 같은 네트워크의 다른 사람이 볼 수 있으니 서버가 https 를 지원하면 https:// 로 바꾸세요.": "http:// 地址会以未加密方式发送用户名、密码和文件。同一网络中的其他人可以看到,如果服务器支持 https,请改为 https://。",
|
||||||
"lsync (실시간 동기화)": "lsync (实时同步)",
|
"lsync (실시간 동기화)": "lsync (实时同步)",
|
||||||
"lsync 로 이동": "移到 lsync",
|
"lsync 로 이동": "移到 lsync",
|
||||||
"quit 시작": "开始退出",
|
"quit 시작": "开始退出",
|
||||||
|
|
@ -232,6 +235,7 @@
|
||||||
"{0} (AI 번역 ← {1})": "{0}(AI 翻译 ← {1})",
|
"{0} (AI 번역 ← {1})": "{0}(AI 翻译 ← {1})",
|
||||||
"{0} (기본): {1}": "{0}(默认): {1}",
|
"{0} (기본): {1}": "{0}(默认): {1}",
|
||||||
"{0} - {1} (약 {2}MB)": "{0} - {1}(约 {2}MB)",
|
"{0} - {1} (약 {2}MB)": "{0} - {1}(约 {2}MB)",
|
||||||
|
"{0} / {1}장": "{0} / {1} 张",
|
||||||
"{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(在主界面顶部更改)",
|
"{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(在主界面顶部更改)",
|
||||||
"{0} → {1} 번역 {2}%": "{0} → {1} 翻译 {2}%",
|
"{0} → {1} 번역 {2}%": "{0} → {1} 翻译 {2}%",
|
||||||
"{0} 로 번역 중 {1}%": "正在翻译为 {0} {1}%",
|
"{0} 로 번역 중 {1}%": "正在翻译为 {0} {1}%",
|
||||||
|
|
@ -384,6 +388,7 @@
|
||||||
"기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "默认: MP4。仅音频时保存为 MP3 · M4A",
|
"기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "默认: MP4。仅音频时保存为 MP3 · M4A",
|
||||||
"기본값": "默认值",
|
"기본값": "默认值",
|
||||||
"기본값으로 ({0})": "恢复默认 ({0})",
|
"기본값으로 ({0})": "恢复默认 ({0})",
|
||||||
|
"기억된 작업: {0} → {1}": "已保存的任务: {0} → {1}",
|
||||||
"기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "没有记住的复制。在文件浏览器中复制 · 移动后会自动登记到这里。",
|
"기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "没有记住的复制。在文件浏览器中复制 · 移动后会自动登记到这里。",
|
||||||
"기타 즐겨찾기": "其他书签",
|
"기타 즐겨찾기": "其他书签",
|
||||||
"길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "长按 · 右键总是打开功能菜单 (复制 · 移动 · 删除 · 重命名 …)。Ctrl + 点击: 多选",
|
"길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "长按 · 右键总是打开功能菜单 (复制 · 移动 · 删除 · 重命名 …)。Ctrl + 点击: 多选",
|
||||||
|
|
@ -420,6 +425,7 @@
|
||||||
"내려받은 Chrome 엔진 지우기": "删除已下载的 Chrome 引擎",
|
"내려받은 Chrome 엔진 지우기": "删除已下载的 Chrome 引擎",
|
||||||
"내려받은 rsync 지우기": "删除已下载的 rsync",
|
"내려받은 rsync 지우기": "删除已下载的 rsync",
|
||||||
"내려받은 변환기 파일을 지웁니다. 다시 켜면 다시 내려받습니다.": "删除已下载的转换器文件。再次开启时会重新下载。",
|
"내려받은 변환기 파일을 지웁니다. 다시 켜면 다시 내려받습니다.": "删除已下载的转换器文件。再次开启时会重新下载。",
|
||||||
|
"내보낸 쿠키 파일을 지웠습니다. 브라우저의 로그인까지 지우려면 웹 브라우저 화면의 [쿠키 · 방문 기록 지우기] 를 누르세요.": "已删除导出的 Cookie 文件。如需同时清除浏览器的登录,请在网页浏览器画面中点按 [清除 Cookie · 浏览记录]。",
|
||||||
"내보냈습니다: {0}": "已导出: {0}",
|
"내보냈습니다: {0}": "已导出: {0}",
|
||||||
"내역": "历史",
|
"내역": "历史",
|
||||||
"내역 (최근 폴더)": "历史 (最近的文件夹)",
|
"내역 (최근 폴더)": "历史 (最近的文件夹)",
|
||||||
|
|
@ -549,6 +555,7 @@
|
||||||
"동영상 + 자막을 MKV 로 합치기 · AI 자막 · 화면 보정": "将视频 + 字幕合并为 MKV · AI 字幕 · 画面调整",
|
"동영상 + 자막을 MKV 로 합치기 · AI 자막 · 화면 보정": "将视频 + 字幕合并为 MKV · AI 字幕 · 画面调整",
|
||||||
"동영상 {0}개 · 전체 선택": "视频 {0} 个 · 全选",
|
"동영상 {0}개 · 전체 선택": "视频 {0} 个 · 全选",
|
||||||
"동영상 {0}개를 MKV 목록에서 뺍니다. 파일은 지우지 않습니다.": "从 MKV 列表中移除 {0} 个视频。不会删除文件。",
|
"동영상 {0}개를 MKV 목록에서 뺍니다. 파일은 지우지 않습니다.": "从 MKV 列表中移除 {0} 个视频。不会删除文件。",
|
||||||
|
"동영상 목록 · 즐겨찾기 · 파일은 그대로입니다": "视频列表 · 收藏 · 文件保持不变",
|
||||||
"동영상 목록 · 창 위치 저장": "保存视频列表 · 窗口位置",
|
"동영상 목록 · 창 위치 저장": "保存视频列表 · 窗口位置",
|
||||||
"동영상 목록에 추가: {0}": "已加入视频列表: {0}",
|
"동영상 목록에 추가: {0}": "已加入视频列表: {0}",
|
||||||
"동영상 선택": "选择视频",
|
"동영상 선택": "选择视频",
|
||||||
|
|
@ -566,8 +573,10 @@
|
||||||
"동영상을 세 번 누르면": "连按视频三次时",
|
"동영상을 세 번 누르면": "连按视频三次时",
|
||||||
"동영상이 있는 폴더 아래 jj_mkv (기본)": "视频所在文件夹下的 jj_mkv(默认)",
|
"동영상이 있는 폴더 아래 jj_mkv (기본)": "视频所在文件夹下的 jj_mkv(默认)",
|
||||||
"동작 중 · {0} 까지": "运行中 · 直到 {0}",
|
"동작 중 · {0} 까지": "运行中 · 直到 {0}",
|
||||||
|
"되돌릴 수 없습니다.": "无法撤销。",
|
||||||
"되살리고 다시 시작": "恢复并重新启动",
|
"되살리고 다시 시작": "恢复并重新启动",
|
||||||
"되살리기": "恢复",
|
"되살리기": "恢复",
|
||||||
|
"두 번 넣은 비밀번호가 다릅니다": "两次输入的密码不一致",
|
||||||
"두 쪽 나눠 보기": "拆分双页",
|
"두 쪽 나눠 보기": "拆分双页",
|
||||||
"두 쪽 나눠 보기: 끄기 (누르면 자동)": "拆分双页: 关闭 (点按切换为自动)",
|
"두 쪽 나눠 보기: 끄기 (누르면 자동)": "拆分双页: 关闭 (点按切换为自动)",
|
||||||
"두 쪽 나눠 보기: 자동 - 가로로 긴 그림만 (누르면 켜기)": "拆分双页: 自动 - 仅横向图片 (点按开启)",
|
"두 쪽 나눠 보기: 자동 - 가로로 긴 그림만 (누르면 켜기)": "拆分双页: 自动 - 仅横向图片 (点按开启)",
|
||||||
|
|
@ -586,9 +595,21 @@
|
||||||
"등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "已注册: “用 JJ_MKVMaker 播放” · “用 JJ_MKVMaker 生成字幕”(Windows 11: 显示更多选项)",
|
"등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "已注册: “用 JJ_MKVMaker 播放” · “用 JJ_MKVMaker 生成字幕”(Windows 11: 显示更多选项)",
|
||||||
"라오어": "老挝语",
|
"라오어": "老挝语",
|
||||||
"러시아어": "俄语",
|
"러시아어": "俄语",
|
||||||
|
"로그인 쿠키는 다운로드 (yt-dlp) 가 쓰도록 이 기기의 앱 폴더에 cookies_youtube.txt 로 저장됩니다 (평문). 이 파일을 가진 사람은 그 계정에 로그인할 수 있으니 기기를 남에게 줄 때는 지우세요. 고른 사이트의 쿠키만 넘깁니다:": "登录 Cookie 会以 cookies_youtube.txt (明文) 保存在本设备的应用文件夹中,供下载 (yt-dlp) 使用。拥有此文件的人可以登录这些账号,将设备交给他人前请删除。仅传递所选网站的 Cookie:",
|
||||||
"로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때": "用其他应用打开需要登录的 WebDAV 视频时",
|
"로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때": "用其他应用打开需要登录的 WebDAV 视频时",
|
||||||
"로그인하면 하루 받기 횟수가 늘어납니다": "登录后每日下载次数会增加",
|
"로그인하면 하루 받기 횟수가 늘어납니다": "登录后每日下载次数会增加",
|
||||||
"루마니아어": "罗马尼亚语",
|
"루마니아어": "罗马尼亚语",
|
||||||
|
"마스터 비밀번호": "主密码",
|
||||||
|
"마스터 비밀번호 넣기": "输入主密码",
|
||||||
|
"마스터 비밀번호 정하기": "设置主密码",
|
||||||
|
"마스터 비밀번호가 필요해 기다리는 중": "需要主密码,正在等待",
|
||||||
|
"마스터 비밀번호로 잠겨 있습니다": "已用主密码锁定",
|
||||||
|
"마스터 비밀번호를 끌까요?": "要关闭主密码吗?",
|
||||||
|
"마스터 비밀번호를 넣어야 저장된 비밀번호를 쓸 수 있습니다": "需要输入主密码才能使用已保存的密码",
|
||||||
|
"마스터 비밀번호를 모르면 저장된 비밀번호를 쓸 수 없습니다. 비밀번호가 새어 나가지 않도록, 저장된 WebDAV · OpenSubtitles 비밀번호와 API 키를 지우고 마스터 비밀번호를 초기화합니다. 서버 목록 · 아이디 · 다른 설정은 그대로이며, 비밀번호는 다시 넣어야 합니다.": "不知道主密码就无法使用已保存的密码。为防止密码泄露,将删除已保存的 WebDAV · OpenSubtitles 密码和 API 密钥,并重置主密码。服务器列表 · 用户名 · 其他设置保持不变,需要重新输入密码。",
|
||||||
|
"마스터 비밀번호를 묻는 때": "何时询问主密码",
|
||||||
|
"마스터 비밀번호를 초기화했습니다": "已重置主密码",
|
||||||
|
"마스터 비밀번호와 같게 정할 수 없습니다": "不能与主密码相同",
|
||||||
"마지막": "上次",
|
"마지막": "上次",
|
||||||
"마지막 인코딩 설정을 기억합니다": "记住上次的编码设置",
|
"마지막 인코딩 설정을 기억합니다": "记住上次的编码设置",
|
||||||
"마지막 장이라 계속 보기를 멈췄습니다.": "已到最后一页,连续浏览已停止。",
|
"마지막 장이라 계속 보기를 멈췄습니다.": "已到最后一页,连续浏览已停止。",
|
||||||
|
|
@ -646,10 +667,12 @@
|
||||||
"무한": "无限制",
|
"무한": "无限制",
|
||||||
"문서 미리보기": "文档预览",
|
"문서 미리보기": "文档预览",
|
||||||
"문자셋 경고": "字符集警告",
|
"문자셋 경고": "字符集警告",
|
||||||
|
"물어보지 않기": "不询问",
|
||||||
"미리 볼 수 없습니다: {0}": "无法预览: {0}",
|
"미리 볼 수 없습니다: {0}": "无法预览: {0}",
|
||||||
"미리보기: 목록에서 동영상을 고르세요": "预览: 请在列表中选择视频",
|
"미리보기: 목록에서 동영상을 고르세요": "预览: 请在列表中选择视频",
|
||||||
"미얀마어": "缅甸语",
|
"미얀마어": "缅甸语",
|
||||||
"미지정": "未指定",
|
"미지정": "未指定",
|
||||||
|
"바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.": "如需更改或关闭,请输入当前主密码 (或最高密码)。",
|
||||||
"바꾸기": "更改",
|
"바꾸기": "更改",
|
||||||
"바꾸면 바로 저장됩니다": "更改后立即保存",
|
"바꾸면 바로 저장됩니다": "更改后立即保存",
|
||||||
"바꾼 것이 없습니다 (원본 그대로)": "未做更改(保持原样)",
|
"바꾼 것이 없습니다 (원본 그대로)": "未做更改(保持原样)",
|
||||||
|
|
@ -711,6 +734,7 @@
|
||||||
"보기": "查看",
|
"보기": "查看",
|
||||||
"보기 {0}초 · Ctrl+휠 확대/축소": "显示 {0} 秒 · Ctrl+滚轮缩放",
|
"보기 {0}초 · Ctrl+휠 확대/축소": "显示 {0} 秒 · Ctrl+滚轮缩放",
|
||||||
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "浏览画面中 [连续浏览] 翻到下一页的间隔",
|
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "浏览画面中 [连续浏览] 翻到下一页的间隔",
|
||||||
|
"보안": "安全",
|
||||||
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "选择要显示的按钮,拖动以调整顺序。",
|
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "选择要显示的按钮,拖动以调整顺序。",
|
||||||
"보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "调整需要重新编码,因此编解码器已从“保持原样”改为 H.264。",
|
"보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "调整需要重新编码,因此编解码器已从“保持原样”改为 H.264。",
|
||||||
"복사": "复制",
|
"복사": "复制",
|
||||||
|
|
@ -747,7 +771,11 @@
|
||||||
"비밀번호 (선택)": "密码(可选)",
|
"비밀번호 (선택)": "密码(可选)",
|
||||||
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "密码 · 字幕 API 密钥不会备份,请重新输入。自动备份大约每天创建一次,之后更改的内容可能缺失。",
|
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "密码 · 字幕 API 密钥不会备份,请重新输入。自动备份大约每天创建一次,之后更改的内容可能缺失。",
|
||||||
"비밀번호 기억 (안전 저장소)": "记住密码 (安全存储)",
|
"비밀번호 기억 (안전 저장소)": "记住密码 (安全存储)",
|
||||||
|
"비밀번호가 저장된 기능을 쓸 때": "使用保存了密码的功能时",
|
||||||
|
"비밀번호가 틀렸습니다 ({0}번)": "密码错误 ({0} 次)",
|
||||||
|
"비밀번호를 넣으세요": "请输入密码",
|
||||||
"비밀번호를 안전 저장소에 저장했습니다": "已将密码保存到安全存储",
|
"비밀번호를 안전 저장소에 저장했습니다": "已将密码保存到安全存储",
|
||||||
|
"비밀번호를 잊었습니다": "忘记密码",
|
||||||
"비어 있는 압축 파일입니다.": "压缩文件为空。",
|
"비어 있는 압축 파일입니다.": "压缩文件为空。",
|
||||||
"비어 있는 폴더": "空文件夹",
|
"비어 있는 폴더": "空文件夹",
|
||||||
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。",
|
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。",
|
||||||
|
|
@ -768,10 +796,12 @@
|
||||||
"삭제: {0}": "已删除: {0}",
|
"삭제: {0}": "已删除: {0}",
|
||||||
"상위 폴더": "上级文件夹",
|
"상위 폴더": "上级文件夹",
|
||||||
"상하 반전": "上下翻转",
|
"상하 반전": "上下翻转",
|
||||||
|
"새 마스터 비밀번호 정하기": "设置新的主密码",
|
||||||
"새 버전 v{0}": "新版本 v{0}",
|
"새 버전 v{0}": "新版本 v{0}",
|
||||||
"새 버전 확인: 지금 v{0} · 최신 v{1}": "检查新版本: 当前 v{0} · 最新 v{1}",
|
"새 버전 확인: 지금 v{0} · 최신 v{1}": "检查新版本: 当前 v{0} · 最新 v{1}",
|
||||||
"새 버전을 확인할 수 없습니다: {0}": "无法检查新版本: {0}",
|
"새 버전을 확인할 수 없습니다: {0}": "无法检查新版本: {0}",
|
||||||
"새 버전이 예전 기본값을 바꿨으면 켤 때 한 번 무엇이 바뀌었는지 알려 줍니다": "如果新版本更改了旧的默认值,启动时提示一次更改了什么",
|
"새 버전이 예전 기본값을 바꿨으면 켤 때 한 번 무엇이 바뀌었는지 알려 줍니다": "如果新版本更改了旧的默认值,启动时提示一次更改了什么",
|
||||||
|
"새 비밀번호": "新密码",
|
||||||
"새 비밀번호는 아직 저장되지 않았습니다. 앱을 끄면 예전 비밀번호로 돌아갑니다.": "新密码尚未保存。关闭应用后会恢复为旧密码。",
|
"새 비밀번호는 아직 저장되지 않았습니다. 앱을 끄면 예전 비밀번호로 돌아갑니다.": "新密码尚未保存。关闭应用后会恢复为旧密码。",
|
||||||
"새 즐겨찾기 추가": "添加新书签",
|
"새 즐겨찾기 추가": "添加新书签",
|
||||||
"새 창에서": "在新窗口中",
|
"새 창에서": "在新窗口中",
|
||||||
|
|
@ -877,6 +907,7 @@
|
||||||
"실시간 동기화 시작": "开始实时同步",
|
"실시간 동기화 시작": "开始实时同步",
|
||||||
"실시간 동기화 실패: {0} → {1}: {2}": "实时同步失败: {0} → {1}: {2}",
|
"실시간 동기화 실패: {0} → {1}: {2}": "实时同步失败: {0} → {1}: {2}",
|
||||||
"실시간 동기화 추가": "添加实时同步",
|
"실시간 동기화 추가": "添加实时同步",
|
||||||
|
"실시간 동기화: {0} → {1}": "实时同步: {0} → {1}",
|
||||||
"실시간 동기화: 대상 폴더": "实时同步: 目标文件夹",
|
"실시간 동기화: 대상 폴더": "实时同步: 目标文件夹",
|
||||||
"실시간 동기화: 원본 폴더": "实时同步: 源文件夹",
|
"실시간 동기화: 원본 폴더": "实时同步: 源文件夹",
|
||||||
"실시간 동기화가 멈췄습니다": "实时同步已停止",
|
"실시간 동기화가 멈췄습니다": "实时同步已停止",
|
||||||
|
|
@ -910,15 +941,20 @@
|
||||||
"아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "仍未能保存。请稍后再试",
|
"아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "仍未能保存。请稍后再试",
|
||||||
"아직 지원하지 않는 플랫폼입니다: {0}": "尚不支持此平台: {0}",
|
"아직 지원하지 않는 플랫폼입니다: {0}": "尚不支持此平台: {0}",
|
||||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)",
|
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)",
|
||||||
|
"암호화 안 됨 (http)": "未加密 (http)",
|
||||||
"압축 파일 안에 없습니다": "压缩文件中没有",
|
"압축 파일 안에 없습니다": "压缩文件中没有",
|
||||||
"압축 파일 안에 프로그램이 없습니다.": "压缩文件中没有程序。",
|
"압축 파일 안에 프로그램이 없습니다.": "压缩文件中没有程序。",
|
||||||
"압축을 풀 수 없습니다: {0}": "无法解压: {0}",
|
"압축을 풀 수 없습니다: {0}": "无法解压: {0}",
|
||||||
"앞의 rsync 가 끝난 뒤에 하세요.": "请等上一个 rsync 结束后再运行。",
|
"앞의 rsync 가 끝난 뒤에 하세요.": "请等上一个 rsync 结束后再运行。",
|
||||||
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "请等待前一个复制 · 移动完成。",
|
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "请等待前一个复制 · 移动完成。",
|
||||||
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "应用列表、主屏幕以及应用内左上角按钮的图标。更改后主屏幕上的图标可能会消失,可能需要从应用列表重新拖放",
|
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "应用列表、主屏幕以及应用内左上角按钮的图标。更改后主屏幕上的图标可能会消失,可能需要从应用列表重新拖放",
|
||||||
|
"앱 설정 전체 (처음 상태로)": "全部应用设置 (恢复初始状态)",
|
||||||
|
"앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "已将应用设置恢复为初始状态 (请重新启动应用以全部生效)",
|
||||||
"앱 아이콘": "应用图标",
|
"앱 아이콘": "应用图标",
|
||||||
"앱 안 브라우저 (권장)": "应用内浏览器(推荐)",
|
"앱 안 브라우저 (권장)": "应用内浏览器(推荐)",
|
||||||
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "使用应用内浏览器的登录(请在浏览器界面登录 YouTube)",
|
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "使用应用内浏览器的登录(请在浏览器界面登录 YouTube)",
|
||||||
|
"앱 안 브라우저의 로그인 쿠키: YouTube · Google 에 더해 Instagram · X · 네이버 (치지직) 로그인도 다운로드 (yt-dlp) 에 씁니다. 고르기 · 끄기 · 지우기: 환경 설정 > 다운로드 > YouTube 쿠키": "应用内浏览器的登录 Cookie: 除 YouTube · Google 外,Instagram · X · Naver (CHZZK) 的登录也会用于下载 (yt-dlp)。选择 · 关闭 · 删除: 设置 > 下载 > YouTube Cookie",
|
||||||
|
"앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. 사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.": "删除应用内浏览器的 Cookie (登录) · 网站数据 · 缓存 · 浏览记录,以及为下载导出的 Cookie 文件。需要重新登录网站。收藏保持不变。",
|
||||||
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "应用内浏览器 · 书签 · 视频下载",
|
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "应用内浏览器 · 书签 · 视频下载",
|
||||||
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)",
|
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)",
|
||||||
"앱 지우기": "卸载应用",
|
"앱 지우기": "卸载应用",
|
||||||
|
|
@ -944,6 +980,7 @@
|
||||||
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "拖放到此处即可加入制作 MKV 的视频列表",
|
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "拖放到此处即可加入制作 MKV 的视频列表",
|
||||||
"여러 개를 골랐을 때": "选择多个时",
|
"여러 개를 골랐을 때": "选择多个时",
|
||||||
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "将多个视频制作成 MKV 时同时处理的数量(重新编码会占用大量 CPU)",
|
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "将多个视频制作成 MKV 时同时处理的数量(重新编码会占用大量 CPU)",
|
||||||
|
"여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "错误次数过多,{0} 秒后可再次输入",
|
||||||
"연결 실패: {0}": "连接失败: {0}",
|
"연결 실패: {0}": "连接失败: {0}",
|
||||||
"연결 확인": "测试连接",
|
"연결 확인": "测试连接",
|
||||||
"연결됨: 항목 {0}개{1}": "已连接: {0} 个项目{1}",
|
"연결됨: 항목 {0}개{1}": "已连接: {0} 个项目{1}",
|
||||||
|
|
@ -1068,9 +1105,11 @@
|
||||||
"이 WebDAV 서버는 로그인이 필요합니다": "此 WebDAV 服务器需要登录",
|
"이 WebDAV 서버는 로그인이 필요합니다": "此 WebDAV 服务器需要登录",
|
||||||
"이 {0}의 설정 파일에 저장됩니다": "保存在此{0}的设置文件中",
|
"이 {0}의 설정 파일에 저장됩니다": "保存在此{0}的设置文件中",
|
||||||
"이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)": "保存在此 {0} 的安全存储中 (不写入设置文件)",
|
"이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)": "保存在此 {0} 的安全存储中 (不写入设置文件)",
|
||||||
|
"이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "本设备只保存无法还原的哈希值,而不是密码本身。",
|
||||||
"이 기기에서는 AI 번역을 쓸 수 없습니다.": "此设备无法使用 AI 翻译。",
|
"이 기기에서는 AI 번역을 쓸 수 없습니다.": "此设备无法使用 AI 翻译。",
|
||||||
"이 기기용 파일 없음": "没有此设备的文件",
|
"이 기기용 파일 없음": "没有此设备的文件",
|
||||||
"이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "本设备的安全存储不可用,无法保存密码",
|
"이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "本设备的安全存储不可用,无法保存密码",
|
||||||
|
"이 기기의 폴더": "本设备上的文件夹",
|
||||||
"이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "无法注册此快捷键(可能已被其他程序使用)",
|
"이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "无法注册此快捷键(可能已被其他程序使用)",
|
||||||
"이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.": "这两个文件夹正在进行 rsync。请等完成后再操作。",
|
"이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.": "这两个文件夹正在进行 rsync。请等完成后再操作。",
|
||||||
"이 버전 건너뛰기": "跳过此版本",
|
"이 버전 건너뛰기": "跳过此版本",
|
||||||
|
|
@ -1080,6 +1119,8 @@
|
||||||
"이 버전으로 업데이트": "更新到此版本",
|
"이 버전으로 업데이트": "更新到此版本",
|
||||||
"이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次进行此复制时将使用这些选项。现在用新选项运行吗?",
|
"이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次进行此复制时将使用这些选项。现在用新选项运行吗?",
|
||||||
"이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "未找到此浏览器的书签文件。",
|
"이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "未找到此浏览器的书签文件。",
|
||||||
|
"이 서버를 쓰는 것 {0}개 (지워도 목록에는 남고, 실행하면 서버가 없다고 알립니다):": "使用此服务器的项目 {0} 个 (删除后仍保留在列表中,运行时会提示服务器不存在):",
|
||||||
|
"이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호가 필요합니다. 넣으면 이어서 맞춥니다. 대상 파일은 건드리지 않았습니다.": "使用此服务器保存的密码需要主密码。输入后将继续同步。未改动目标文件。",
|
||||||
"이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "此安装中没有 rsync (使用 rsync 复制时 · 或用右侧 [下载] 获取)",
|
"이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "此安装中没有 rsync (使用 rsync 复制时 · 或用右侧 [下载] 获取)",
|
||||||
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "此安装中没有 rsync。请自行指定。",
|
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "此安装中没有 rsync。请自行指定。",
|
||||||
"이 언어 지우기": "删除此语言",
|
"이 언어 지우기": "删除此语言",
|
||||||
|
|
@ -1091,6 +1132,7 @@
|
||||||
"이 페이지 번역": "翻译此页",
|
"이 페이지 번역": "翻译此页",
|
||||||
"이 페이지 지우기": "删除此页",
|
"이 페이지 지우기": "删除此页",
|
||||||
"이 페이지를 {0}(으)로 번역": "将此页面翻译为{0}",
|
"이 페이지를 {0}(으)로 번역": "将此页面翻译为{0}",
|
||||||
|
"이 폴더 고르기": "选择此文件夹",
|
||||||
"이 폴더 선택": "选择此文件夹",
|
"이 폴더 선택": "选择此文件夹",
|
||||||
"이 폴더 쌍은 기억한 옵션을 씁니다 (환경 설정이 아니라 모니터링 > 복사 · rsync 에서 고칩니다)": "此文件夹组合使用记住的选项 (请在 监视 > 复制 · rsync 中修改,而非设置)",
|
"이 폴더 쌍은 기억한 옵션을 씁니다 (환경 설정이 아니라 모니터링 > 복사 · rsync 에서 고칩니다)": "此文件夹组合使用记住的选项 (请在 监视 > 复制 · rsync 中修改,而非设置)",
|
||||||
"이 폴더 전체": "整个文件夹",
|
"이 폴더 전체": "整个文件夹",
|
||||||
|
|
@ -1235,8 +1277,14 @@
|
||||||
"저장 안 함": "不保存",
|
"저장 안 함": "不保存",
|
||||||
"저장 위치": "保存位置",
|
"저장 위치": "保存位置",
|
||||||
"저장: {0}": "保存: {0}",
|
"저장: {0}": "保存: {0}",
|
||||||
|
"저장된 비밀번호 · API 키를 지웠습니다": "已删除保存的密码 · API 密钥",
|
||||||
|
"저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다. 잊으면 최상 비밀번호로 초기화하거나, 저장된 비밀번호를 지우고 초기화할 수 있습니다.": "使用已保存的密码 · 设置 · 需要密码的功能时询问。忘记时可用最高密码重置,或删除已保存的密码后重置。",
|
||||||
|
"저장된 비밀번호 · 설정을 쓸 때 더는 묻지 않습니다.": "使用已保存的密码 · 设置时将不再询问。",
|
||||||
"저장된 비밀번호가 없습니다. 한 번만 다시 넣어 주세요": "没有保存的密码。请再输入一次",
|
"저장된 비밀번호가 없습니다. 한 번만 다시 넣어 주세요": "没有保存的密码。请再输入一次",
|
||||||
"저장된 비밀번호를 되살리지 못했습니다": "无法恢复已保存的密码",
|
"저장된 비밀번호를 되살리지 못했습니다": "无法恢复已保存的密码",
|
||||||
|
"저장된 비밀번호를 쓰는 기능 · 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "要使用保存了密码的功能或打开设置,请输入主密码。在关闭应用前不会再次询问。",
|
||||||
|
"저장된 비밀번호를 지우고 마스터 초기화": "删除已保存的密码并重置主密码",
|
||||||
|
"저장된 실제 비밀번호 · API 키": "已保存的实际密码 · API 密钥",
|
||||||
"저장된 옵션으로 실행": "使用已保存的选项执行",
|
"저장된 옵션으로 실행": "使用已保存的选项执行",
|
||||||
"저장하고 검색": "保存并搜索",
|
"저장하고 검색": "保存并搜索",
|
||||||
"저장하고 계속": "保存并继续",
|
"저장하고 계속": "保存并继续",
|
||||||
|
|
@ -1263,10 +1311,16 @@
|
||||||
"정리할 파일을 찾는 중…": "正在查找可清理的文件…",
|
"정리할 파일을 찾는 중…": "正在查找可清理的文件…",
|
||||||
"정리할 파일이 없었습니다.": "没有可清理的文件。",
|
"정리할 파일이 없었습니다.": "没有可清理的文件。",
|
||||||
"정리했습니다: 파일 {0}개 · {1}": "已清理: 文件 {0} 个 · {1}",
|
"정리했습니다: 파일 {0}개 · {1}": "已清理: 文件 {0} 个 · {1}",
|
||||||
|
"정말 초기화할까요?": "确定要重置吗?",
|
||||||
"정보": "信息",
|
"정보": "信息",
|
||||||
"정사각 1:1": "正方形 1:1",
|
"정사각 1:1": "正方形 1:1",
|
||||||
"정지": "停止",
|
"정지": "停止",
|
||||||
|
"정하기": "设置",
|
||||||
|
"정하지 않음. 마스터를 잊으면 저장된 비밀번호를 지우고 마스터를 초기화하는 것만 할 수 있습니다": "未设置。忘记主密码时,只能删除已保存的密码并重置主密码",
|
||||||
|
"정하지 않음. 정하면 저장된 비밀번호 (WebDAV · OpenSubtitles · API 키) 와 설정을 쓰기 전에 묻습니다": "未设置。设置后,在使用已保存的密码 (WebDAV · OpenSubtitles · API 密钥) 和设置前询问",
|
||||||
"정하지 않음: MKV 목록에서 [이동] 을 처음 누를 때 고릅니다": "未设置: 首次在 MKV 列表中点击[移动]时选择",
|
"정하지 않음: MKV 목록에서 [이동] 을 처음 누를 때 고릅니다": "未设置: 首次在 MKV 列表中点击[移动]时选择",
|
||||||
|
"정해 둠. 마스터 입력 창에 넣으면 초기화 창이 열립니다 (마스터 · 저장된 비밀번호 · 앱 설정 전체 중 고름)": "已设置。在主密码输入框中输入后会打开重置窗口 (从主密码 · 已保存的密码 · 全部应用设置中选择)",
|
||||||
|
"정해 둠. 저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다 (앱을 끌 때까지 한 번)": "已设置。使用已保存的密码 · 设置 · 需要密码的功能时询问 (在关闭应用前只问一次)",
|
||||||
"제목": "标题",
|
"제목": "标题",
|
||||||
"조정 전": "调整前",
|
"조정 전": "调整前",
|
||||||
"종료": "退出",
|
"종료": "退出",
|
||||||
|
|
@ -1318,6 +1372,7 @@
|
||||||
"지금": "当前",
|
"지금": "当前",
|
||||||
"지금 {0}%": "当前 {0}%",
|
"지금 {0}%": "当前 {0}%",
|
||||||
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "正在运行的复制使用的是之前的选项。停止并用新选项重新运行吗?",
|
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "正在运行的复制使用的是之前的选项。停止并用新选项重新运行吗?",
|
||||||
|
"지금 마스터 (또는 최상) 비밀번호": "当前主密码 (或最高密码)",
|
||||||
"지금 맞추기": "立即同步",
|
"지금 맞추기": "立即同步",
|
||||||
"지금 맞추는 중입니다. 끝난 뒤에 하세요.": "正在同步。请等结束后再进行。",
|
"지금 맞추는 중입니다. 끝난 뒤에 하세요.": "正在同步。请等结束后再进行。",
|
||||||
"지금 버전 (v{0}) 의 설정을 보관했습니다: {1}": "已保存当前版本 (v{0}) 的设置: {1}",
|
"지금 버전 (v{0}) 의 설정을 보관했습니다: {1}": "已保存当前版本 (v{0}) 的设置: {1}",
|
||||||
|
|
@ -1333,6 +1388,7 @@
|
||||||
"지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "当前是双开应用 (复制的应用)。Android 可能不会给双开应用此权限。如果无法开启,请从没有徽标的原 JJ_MKVMaker 图标打开。",
|
"지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "当前是双开应用 (复制的应用)。Android 可能不会给双开应用此权限。如果无法开启,请从没有徽标的原 JJ_MKVMaker 图标打开。",
|
||||||
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "上次运行未正常结束(最后检查 {0}:{1})。已记录在任务记录中。",
|
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "上次运行未正常结束(最后检查 {0}:{1})。已记录在任务记录中。",
|
||||||
"지난 작업 기록": "旧任务记录",
|
"지난 작업 기록": "旧任务记录",
|
||||||
|
"지우고 초기화": "删除并重置",
|
||||||
"지우기": "删除",
|
"지우기": "删除",
|
||||||
"지우기 포함": "含删除",
|
"지우기 포함": "含删除",
|
||||||
"지우기가 들어 있는 작업입니다": "此任务包含删除",
|
"지우기가 들어 있는 작업입니다": "此任务包含删除",
|
||||||
|
|
@ -1376,6 +1432,7 @@
|
||||||
"채도": "饱和度",
|
"채도": "饱和度",
|
||||||
"채우기 (가운데 잘라냄)": "填充(裁切中间)",
|
"채우기 (가운데 잘라냄)": "填充(裁切中间)",
|
||||||
"처음 사용 시 내려받기 {0}": "首次使用时下载 {0}",
|
"처음 사용 시 내려받기 {0}": "首次使用时下载 {0}",
|
||||||
|
"처음 시작 시": "启动时",
|
||||||
"처음 쓸 때 내려받기 (기본)": "首次使用时下载 (默认)",
|
"처음 쓸 때 내려받기 (기본)": "首次使用时下载 (默认)",
|
||||||
"처음 아이콘 (파랑)": "最初的图标(蓝色)",
|
"처음 아이콘 (파랑)": "最初的图标(蓝色)",
|
||||||
"첫 장입니다.": "已是第一页。",
|
"첫 장입니다.": "已是第一页。",
|
||||||
|
|
@ -1383,7 +1440,16 @@
|
||||||
"청각장애인용": "听障字幕",
|
"청각장애인용": "听障字幕",
|
||||||
"체코어": "捷克语",
|
"체코어": "捷克语",
|
||||||
"체크한 동영상 {0}개의 {1}": "勾选的 {0} 个视频的{1}",
|
"체크한 동영상 {0}개의 {1}": "勾选的 {0} 个视频的{1}",
|
||||||
|
"초기화": "重置",
|
||||||
|
"초기화할 것을 고르세요.": "请选择要重置的内容。",
|
||||||
|
"초기화했습니다": "已重置",
|
||||||
"최고 화질": "最高画质",
|
"최고 화질": "最高画质",
|
||||||
|
"최상 비밀번호": "最高密码",
|
||||||
|
"최상 비밀번호 - 초기화": "最高密码 - 重置",
|
||||||
|
"최상 비밀번호 정하기": "设置最高密码",
|
||||||
|
"최상 비밀번호는 모든 것을 초기화하는 열쇠입니다. 마스터 입력 창에 넣으면 초기화 창이 열립니다.": "最高密码是可以重置一切的钥匙。在主密码输入框中输入后会打开重置窗口。",
|
||||||
|
"최상 비밀번호를 끌까요?": "要关闭最高密码吗?",
|
||||||
|
"최상 비밀번호와 같게 정할 수 없습니다": "不能与最高密码相同",
|
||||||
"최소화하면 트레이로 (백그라운드 실행)": "最小化到托盘(后台运行)",
|
"최소화하면 트레이로 (백그라운드 실행)": "最小化到托盘(后台运行)",
|
||||||
"최신": "最新",
|
"최신": "最新",
|
||||||
"최신 버전 확인": "检查最新版本",
|
"최신 버전 확인": "检查最新版本",
|
||||||
|
|
@ -1411,6 +1477,9 @@
|
||||||
"켜면 휴지통으로 보내 되살릴 수 있습니다 (Shift+Delete 는 영구 삭제). 끄면 늘 영구 삭제로 묻습니다. 네트워크 드라이브 · USB 메모리는 휴지통이 없어 늘 영구 삭제입니다": "开启后移到回收站,可以恢复 (Shift+Delete 为永久删除)。关闭后始终按永久删除询问。网络驱动器 · U 盘没有回收站,始终永久删除",
|
"켜면 휴지통으로 보내 되살릴 수 있습니다 (Shift+Delete 는 영구 삭제). 끄면 늘 영구 삭제로 묻습니다. 네트워크 드라이브 · USB 메모리는 휴지통이 없어 늘 영구 삭제입니다": "开启后移到回收站,可以恢复 (Shift+Delete 为永久删除)。关闭后始终按永久删除询问。网络驱动器 · U 盘没有回收站,始终永久删除",
|
||||||
"켜져 있는 창에서 (기본)": "在已打开的窗口中(默认)",
|
"켜져 있는 창에서 (기본)": "在已打开的窗口中(默认)",
|
||||||
"코덱": "编解码器",
|
"코덱": "编解码器",
|
||||||
|
"쿠키 · 방문 기록 지우기": "清除 Cookie · 浏览记录",
|
||||||
|
"쿠키 · 방문 기록을 지웠습니다.": "已清除 Cookie · 浏览记录。",
|
||||||
|
"쿠키 파일 지우기": "删除 Cookie 文件",
|
||||||
"크기": "大小",
|
"크기": "大小",
|
||||||
"크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "大小 · 调整已恢复原样,编解码器也恢复为“保持原样”。",
|
"크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "大小 · 调整已恢复原样,编解码器也恢复为“保持原样”。",
|
||||||
"크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "更改大小需要重新编码,因此编解码器已从“保持原样”改为 H.264。",
|
"크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "更改大小需要重新编码,因此编解码器已从“保持原样”改为 H.264。",
|
||||||
|
|
@ -1513,6 +1582,7 @@
|
||||||
"필수 프로그램 점검": "检查必需程序",
|
"필수 프로그램 점검": "检查必需程序",
|
||||||
"필수 프로그램이 모두 설치되어 있습니다.": "所有必需程序均已安装。",
|
"필수 프로그램이 모두 설치되어 있습니다.": "所有必需程序均已安装。",
|
||||||
"하위 폴더가 없습니다": "没有子文件夹",
|
"하위 폴더가 없습니다": "没有子文件夹",
|
||||||
|
"한 번 더": "再输入一次",
|
||||||
"한 번: 바로 실행": "单击: 立即打开",
|
"한 번: 바로 실행": "单击: 立即打开",
|
||||||
"한 번: 바로 실행 (기본)": "单击: 直接打开 (默认)",
|
"한 번: 바로 실행 (기본)": "单击: 直接打开 (默认)",
|
||||||
"한 번: 보기 · 두 번: 재생 · 세 번: 이동 폴더로 옮기기 (옮기기 전에 묻습니다)": "一次: 查看 · 两次: 播放 · 三次: 移到移动文件夹 (移动前询问)",
|
"한 번: 보기 · 두 번: 재생 · 세 번: 이동 폴더로 옮기기 (옮기기 전에 묻습니다)": "一次: 查看 · 两次: 播放 · 三次: 移到移动文件夹 (移动前询问)",
|
||||||
|
|
@ -1568,6 +1638,7 @@
|
||||||
"확장자별 재생 프로그램": "按扩展名设置播放程序",
|
"확장자별 재생 프로그램": "按扩展名设置播放程序",
|
||||||
"환경 설정": "设置",
|
"환경 설정": "设置",
|
||||||
"환경 설정 열기": "打开设置",
|
"환경 설정 열기": "打开设置",
|
||||||
|
"환경 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "要打开设置,请输入主密码。在关闭应用前不会再次询问。",
|
||||||
"회전": "旋转",
|
"회전": "旋转",
|
||||||
"회전 없음": "不旋转",
|
"회전 없음": "不旋转",
|
||||||
"회차": "集数",
|
"회차": "集数",
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,26 @@ void main() {
|
||||||
await s.delete(key);
|
await s.delete(key);
|
||||||
expect((await s.readAll()).containsKey(key), isFalse);
|
expect((await s.readAll()).containsKey(key), isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('130: 두 창 (저장소 두 개) 이 번갈아 써도 서로의 값을 지우지 않는다', (t) async {
|
||||||
|
const a = 'jj.selftest.a', b = 'jj.selftest.b';
|
||||||
|
final first = SecretStore.platform(), second = SecretStore.platform();
|
||||||
|
try {
|
||||||
|
await first.readAll(); // 첫 창이 읽어 둠
|
||||||
|
await second.write(b, '두 번째 창'); // 두 번째 창이 씀
|
||||||
|
await first.write(a, '첫 창'); // 첫 창이 쓸 때 두 번째 창의 값을 지우면 안 된다
|
||||||
|
final now = await SecretStore.platform().readAll();
|
||||||
|
expect(now[a], '첫 창');
|
||||||
|
expect(now[b], '두 번째 창');
|
||||||
|
await second.delete(b); // 두 번째 창이 지울 때 첫 창의 값을 되살리거나 지우면 안 된다
|
||||||
|
final after = await first.readAll();
|
||||||
|
expect(after[a], '첫 창');
|
||||||
|
expect(after.containsKey(b), isFalse);
|
||||||
|
} finally {
|
||||||
|
await SecretStore.platform().delete(a);
|
||||||
|
await SecretStore.platform().delete(b);
|
||||||
|
}
|
||||||
|
final end = await SecretStore.platform().readAll();
|
||||||
|
expect(end.containsKey(a) || end.containsKey(b), isFalse);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -395,7 +395,14 @@ class LiveSync extends ChangeNotifier {
|
||||||
} on SourceUnreadableException catch (e) {
|
} on SourceUnreadableException catch (e) {
|
||||||
// 68 · 70: 대상은 건드리지 않고 멈춤. 카드에 빨갛게 · 작업 알림에 · 로그에
|
// 68 · 70: 대상은 건드리지 않고 멈춤. 카드에 빨갛게 · 작업 알림에 · 로그에
|
||||||
problems[k] = e;
|
problems[k] = e;
|
||||||
status[k] = (DateTime.now(), e.empty ? tr('원본 폴더가 비어 있어 멈춤') : tr('원본을 읽을 수 없어 멈춤'));
|
status[k] = (
|
||||||
|
DateTime.now(),
|
||||||
|
e.locked
|
||||||
|
? tr('마스터 비밀번호가 필요해 기다리는 중')
|
||||||
|
: e.empty
|
||||||
|
? tr('원본 폴더가 비어 있어 멈춤')
|
||||||
|
: tr('원본을 읽을 수 없어 멈춤')
|
||||||
|
);
|
||||||
if (e.empty) {
|
if (e.empty) {
|
||||||
try {
|
try {
|
||||||
emptyDeletes[k] = [for (final d in await diff(asked, limit: 100000)) if (d.startsWith('− ')) d.substring(2)];
|
emptyDeletes[k] = [for (final d in await diff(asked, limit: 100000)) if (d.startsWith('− ')) d.substring(2)];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,334 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../services/secret_store.dart';
|
||||||
|
|
||||||
|
/// PBKDF2-HMAC-SHA256 ([length] 바이트).
|
||||||
|
/// 반복마다 HMAC 의 안 · 바깥 열쇠 블록을 다시 계산하지 않도록 SHA-256 을 직접 돌린다 (폰에서도 빠르게).
|
||||||
|
Uint8List pbkdf2Sha256(List<int> password, List<int> salt, int iterations, int length) {
|
||||||
|
var key = password;
|
||||||
|
if (key.length > 64) key = sha256.convert(key).bytes;
|
||||||
|
final ipad = Uint8List(64), opad = Uint8List(64);
|
||||||
|
for (var i = 0; i < 64; i++) {
|
||||||
|
final k = i < key.length ? key[i] : 0;
|
||||||
|
ipad[i] = k ^ 0x36;
|
||||||
|
opad[i] = k ^ 0x5c;
|
||||||
|
}
|
||||||
|
// 안 · 바깥 열쇠 블록을 한 번 넣은 상태
|
||||||
|
final inner = Uint32List.fromList(_h0), outer = Uint32List.fromList(_h0);
|
||||||
|
final w = Uint32List(64);
|
||||||
|
_compress(inner, ipad, 0, w);
|
||||||
|
_compress(outer, opad, 0, w);
|
||||||
|
|
||||||
|
// 32 바이트 메시지 하나 (앞 블록 64 바이트 다음) 의 HMAC: 패딩까지 넣은 한 블록
|
||||||
|
final block = Uint8List(64);
|
||||||
|
block[32] = 0x80;
|
||||||
|
block[62] = 0x03; // (64 + 32) * 8 = 768 비트 = 0x300
|
||||||
|
final st = Uint32List(8);
|
||||||
|
Uint8List hmac32(Uint8List msg) {
|
||||||
|
block.setRange(0, 32, msg);
|
||||||
|
st.setAll(0, inner);
|
||||||
|
_compress(st, block, 0, w);
|
||||||
|
_store(st, block);
|
||||||
|
st.setAll(0, outer);
|
||||||
|
_compress(st, block, 0, w);
|
||||||
|
final out = Uint8List(32);
|
||||||
|
_store(st, out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
final hmac = Hmac(sha256, password);
|
||||||
|
final out = BytesBuilder();
|
||||||
|
for (var n = 1; out.length < length; n++) {
|
||||||
|
var u = Uint8List.fromList(hmac.convert([...salt, n >> 24 & 0xff, n >> 16 & 0xff, n >> 8 & 0xff, n & 0xff]).bytes);
|
||||||
|
final t = Uint8List.fromList(u);
|
||||||
|
for (var i = 1; i < iterations; i++) {
|
||||||
|
u = hmac32(u);
|
||||||
|
for (var k = 0; k < 32; k++) {
|
||||||
|
t[k] ^= u[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.add(t);
|
||||||
|
}
|
||||||
|
return Uint8List.fromList(out.toBytes().sublist(0, length));
|
||||||
|
}
|
||||||
|
|
||||||
|
const _h0 = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
|
||||||
|
const _k = [
|
||||||
|
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, //
|
||||||
|
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||||
|
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||||
|
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||||
|
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||||
|
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||||
|
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||||
|
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||||
|
];
|
||||||
|
|
||||||
|
int _rotr(int x, int n) => ((x >> n) | (x << (32 - n))) & 0xffffffff;
|
||||||
|
|
||||||
|
/// SHA-256 블록 하나 ([data] 의 [off] 부터 64 바이트) 를 [h] 에 더한다
|
||||||
|
void _compress(Uint32List h, Uint8List data, int off, Uint32List w) {
|
||||||
|
for (var i = 0; i < 16; i++) {
|
||||||
|
final j = off + i * 4;
|
||||||
|
w[i] = data[j] << 24 | data[j + 1] << 16 | data[j + 2] << 8 | data[j + 3];
|
||||||
|
}
|
||||||
|
for (var i = 16; i < 64; i++) {
|
||||||
|
final a = w[i - 15], b = w[i - 2];
|
||||||
|
final s0 = _rotr(a, 7) ^ _rotr(a, 18) ^ (a >> 3);
|
||||||
|
final s1 = _rotr(b, 17) ^ _rotr(b, 19) ^ (b >> 10);
|
||||||
|
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) & 0xffffffff;
|
||||||
|
}
|
||||||
|
var a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], hh = h[7];
|
||||||
|
for (var i = 0; i < 64; i++) {
|
||||||
|
final s1 = _rotr(e, 6) ^ _rotr(e, 11) ^ _rotr(e, 25);
|
||||||
|
final ch = (e & f) ^ (~e & 0xffffffff & g);
|
||||||
|
final t1 = (hh + s1 + ch + _k[i] + w[i]) & 0xffffffff;
|
||||||
|
final s0 = _rotr(a, 2) ^ _rotr(a, 13) ^ _rotr(a, 22);
|
||||||
|
final maj = (a & b) ^ (a & c) ^ (b & c);
|
||||||
|
final t2 = (s0 + maj) & 0xffffffff;
|
||||||
|
hh = g;
|
||||||
|
g = f;
|
||||||
|
f = e;
|
||||||
|
e = (d + t1) & 0xffffffff;
|
||||||
|
d = c;
|
||||||
|
c = b;
|
||||||
|
b = a;
|
||||||
|
a = (t1 + t2) & 0xffffffff;
|
||||||
|
}
|
||||||
|
h[0] = (h[0] + a) & 0xffffffff;
|
||||||
|
h[1] = (h[1] + b) & 0xffffffff;
|
||||||
|
h[2] = (h[2] + c) & 0xffffffff;
|
||||||
|
h[3] = (h[3] + d) & 0xffffffff;
|
||||||
|
h[4] = (h[4] + e) & 0xffffffff;
|
||||||
|
h[5] = (h[5] + f) & 0xffffffff;
|
||||||
|
h[6] = (h[6] + g) & 0xffffffff;
|
||||||
|
h[7] = (h[7] + hh) & 0xffffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _store(Uint32List h, Uint8List out) {
|
||||||
|
for (var i = 0; i < 8; i++) {
|
||||||
|
out[i * 4] = h[i] >> 24 & 0xff;
|
||||||
|
out[i * 4 + 1] = h[i] >> 16 & 0xff;
|
||||||
|
out[i * 4 + 2] = h[i] >> 8 & 0xff;
|
||||||
|
out[i * 4 + 3] = h[i] & 0xff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 비밀번호 해시 ("pbkdf2-sha256$반복$솔트$해시", 솔트 · 해시는 base64). 원문은 남기지 않는다.
|
||||||
|
class PasswordHash {
|
||||||
|
static const scheme = 'pbkdf2-sha256';
|
||||||
|
static const defaultIterations = 100000;
|
||||||
|
|
||||||
|
static String make(String password, {List<int>? salt, int iterations = defaultIterations}) {
|
||||||
|
final s = salt ?? List<int>.generate(16, (_) => Random.secure().nextInt(256));
|
||||||
|
final h = pbkdf2Sha256(utf8.encode(password), s, iterations, 32);
|
||||||
|
return '$scheme\$$iterations\$${base64Encode(s)}\$${base64Encode(h)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool verify(String password, String stored) {
|
||||||
|
final parts = stored.split('\$');
|
||||||
|
if (parts.length != 4 || parts[0] != scheme) return false;
|
||||||
|
final n = int.tryParse(parts[1]);
|
||||||
|
if (n == null || n < 1) return false;
|
||||||
|
final List<int> salt, want;
|
||||||
|
try {
|
||||||
|
salt = base64Decode(parts[2]);
|
||||||
|
want = base64Decode(parts[3]);
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final got = pbkdf2Sha256(utf8.encode(password), salt, n, want.length);
|
||||||
|
// 걸린 시간으로 맞은 글자 수가 드러나지 않게 끝까지 비교
|
||||||
|
var diff = got.length ^ want.length;
|
||||||
|
for (var i = 0; i < min(got.length, want.length); i++) {
|
||||||
|
diff |= got[i] ^ want[i];
|
||||||
|
}
|
||||||
|
return diff == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 화면이 멈추지 않게 따로 돌린다
|
||||||
|
static Future<String> makeAsync(String password) => compute(_make, password);
|
||||||
|
static Future<bool> verifyAsync(String password, String stored) => compute(_verify, [password, stored]);
|
||||||
|
static String _make(String p) => make(p);
|
||||||
|
static bool _verify(List<String> a) => verify(a[0], a[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 마스터 입력 창에 넣은 값이 무엇이었는지
|
||||||
|
enum MasterInput { master, superPassword, wrong, wait }
|
||||||
|
|
||||||
|
/// 124: 비밀번호 세 가지 중 ① 최상 (모두 초기화하는 열쇠) · ② 마스터 (저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때).
|
||||||
|
/// 둘 다 사용자가 정하고, 솔트를 넣은 느린 해시로 안전 저장소에만 둔다. ③ 실제 비밀번호는 [SettingsStore] 가 다룬다.
|
||||||
|
///
|
||||||
|
/// 마스터는 기능을 여는 자물쇠다 (실제 비밀번호를 마스터로 암호화하지는 않는다 - 안전 저장소가 기기 열쇠로 이미 암호화).
|
||||||
|
/// 한 번 풀면 앱이 켜져 있는 동안 풀린 채로 둔다 (완전히 끄면 다시 묻는다).
|
||||||
|
class MasterLock extends ChangeNotifier {
|
||||||
|
MasterLock(this.secrets, {this.now = DateTime.now});
|
||||||
|
|
||||||
|
final SecretStore secrets;
|
||||||
|
final DateTime Function() now;
|
||||||
|
|
||||||
|
static const masterKey = 'sec.master', superKey = 'sec.super', failKey = 'sec.fails', askKey = 'sec.ask';
|
||||||
|
static const askValues = ['never', 'startup', 'onUse'];
|
||||||
|
|
||||||
|
/// 앱에서 쓰는 것 (main 이 정한다)
|
||||||
|
static MasterLock? instance;
|
||||||
|
|
||||||
|
/// 마스터를 묻는 창 (main 이 화면에 띄우는 함수를 넣는다, [reason] 은 창에 보일 까닭). 풀렸으면 true
|
||||||
|
static Future<bool> Function(String? reason)? prompt;
|
||||||
|
|
||||||
|
String? _master, _super, _askStored;
|
||||||
|
bool unlocked = false;
|
||||||
|
|
||||||
|
/// 128: 이번 실행에서 마스터 창을 취소했다 - 자동 · 배경 작업은 다시 묻지 않고 건너뛴다 (사용자가 직접 누른 곳만 다시 묻는다)
|
||||||
|
bool declined = false;
|
||||||
|
|
||||||
|
/// 연달아 틀린 수 · 이때까지 기다림 (앱을 껐다 켜도 남게 안전 저장소에)
|
||||||
|
int fails = 0;
|
||||||
|
DateTime? waitUntil;
|
||||||
|
|
||||||
|
bool get hasMaster => _master != null;
|
||||||
|
bool get hasSuper => _super != null;
|
||||||
|
|
||||||
|
/// 'never' 물어보지 않기 · 'startup' 처음 시작 시 · 'onUse' 비밀번호가 저장된 기능을 쓸 때.
|
||||||
|
/// 129: 평문 설정 파일이 아니라 안전 저장소에 둔다. 마스터가 있는데 값이 없거나 틀리면 'onUse' (자물쇠가 말없이 꺼지지 않게)
|
||||||
|
String get ask {
|
||||||
|
if (!hasMaster) return 'never';
|
||||||
|
final a = _askStored;
|
||||||
|
return a != null && askValues.contains(a) ? a : 'onUse';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setAsk(String value) async {
|
||||||
|
if (!askValues.contains(value)) return;
|
||||||
|
_askStored = value;
|
||||||
|
await secrets.write(askKey, value);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> load() async {
|
||||||
|
final all = await secrets.readAll();
|
||||||
|
_master = all[masterKey];
|
||||||
|
_super = all[superKey];
|
||||||
|
_askStored = all[askKey];
|
||||||
|
final f = (all[failKey] ?? '').split('|');
|
||||||
|
fails = int.tryParse(f.first) ?? 0;
|
||||||
|
final until = f.length > 1 ? int.tryParse(f[1]) : null;
|
||||||
|
waitUntil = until == null || until == 0 ? null : DateTime.fromMillisecondsSinceEpoch(until);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 지금 기다려야 하는 시간 (없으면 null)
|
||||||
|
Duration? get waiting {
|
||||||
|
final w = waitUntil;
|
||||||
|
if (w == null) return null;
|
||||||
|
final left = w.difference(now());
|
||||||
|
return left.isNegative ? null : left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 5번째 틀림부터 30초 · 1분 · 2분 … (최대 15분) 기다리게 한다
|
||||||
|
static Duration waitFor(int fails) {
|
||||||
|
if (fails < 5) return Duration.zero;
|
||||||
|
final s = 30 * (1 << min(fails - 5, 5));
|
||||||
|
return Duration(seconds: min(s, 15 * 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveFails() =>
|
||||||
|
secrets.write(failKey, '$fails|${waitUntil?.millisecondsSinceEpoch ?? 0}');
|
||||||
|
|
||||||
|
/// 마스터 입력 창의 값을 확인한다 (최상을 넣으면 [MasterInput.superPassword] - 초기화 창으로)
|
||||||
|
Future<MasterInput> check(String input) async {
|
||||||
|
if (waiting != null) return MasterInput.wait;
|
||||||
|
final m = _master, s = _super;
|
||||||
|
MasterInput r = MasterInput.wrong;
|
||||||
|
if (m != null && await PasswordHash.verifyAsync(input, m)) {
|
||||||
|
r = MasterInput.master;
|
||||||
|
} else if (s != null && await PasswordHash.verifyAsync(input, s)) {
|
||||||
|
r = MasterInput.superPassword;
|
||||||
|
}
|
||||||
|
if (r == MasterInput.master) declined = false;
|
||||||
|
if (r == MasterInput.wrong) {
|
||||||
|
fails++;
|
||||||
|
final w = waitFor(fails);
|
||||||
|
waitUntil = w == Duration.zero ? null : now().add(w);
|
||||||
|
} else {
|
||||||
|
fails = 0;
|
||||||
|
waitUntil = null;
|
||||||
|
if (r == MasterInput.master) unlocked = true;
|
||||||
|
}
|
||||||
|
await _saveFails();
|
||||||
|
notifyListeners();
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 마스터를 정한다 · 바꾼다. 최상과 같으면 false (정하지 않음)
|
||||||
|
Future<bool> setMaster(String password) async {
|
||||||
|
final s = _super;
|
||||||
|
if (s != null && await PasswordHash.verifyAsync(password, s)) return false;
|
||||||
|
_master = await PasswordHash.makeAsync(password);
|
||||||
|
await secrets.write(masterKey, _master!);
|
||||||
|
unlocked = true;
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 최상을 정한다 · 바꾼다. 마스터와 같으면 false
|
||||||
|
Future<bool> setSuper(String password) async {
|
||||||
|
final m = _master;
|
||||||
|
if (m != null && await PasswordHash.verifyAsync(password, m)) return false;
|
||||||
|
_super = await PasswordHash.makeAsync(password);
|
||||||
|
await secrets.write(superKey, _super!);
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearMaster() async {
|
||||||
|
_master = null;
|
||||||
|
_askStored = null;
|
||||||
|
await secrets.delete(masterKey);
|
||||||
|
await secrets.delete(askKey);
|
||||||
|
unlocked = true;
|
||||||
|
declined = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearSuper() async {
|
||||||
|
_super = null;
|
||||||
|
await secrets.delete(superKey);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 마스터를 물어야 하는지 ([startup]: 앱을 켤 때 묻는 자리인지)
|
||||||
|
bool needsPrompt({bool startup = false}) {
|
||||||
|
if (!hasMaster || unlocked) return false;
|
||||||
|
return switch (ask) {
|
||||||
|
'never' => false,
|
||||||
|
'startup' => true, // 켤 때 묻고, 그때 취소했으면 쓸 때 다시 묻는다
|
||||||
|
_ => !startup,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool>? _pending;
|
||||||
|
|
||||||
|
/// 비밀번호가 저장된 기능을 쓰기 전에: 필요하면 마스터를 묻는다. 써도 되면 true.
|
||||||
|
/// - 시작할 때 · 설정 화면 · 배경 작업이 동시에 불러도 창은 하나 (128).
|
||||||
|
/// - 한 번 취소하면 이번 실행 동안 자동 · 배경 호출은 묻지 않고 false. [force]: 사용자가 직접 누른 곳 (다시 묻는다)
|
||||||
|
Future<bool> ensure({bool force = false, String? reason}) {
|
||||||
|
if (!needsPrompt()) return Future.value(true);
|
||||||
|
final pending = _pending;
|
||||||
|
if (pending != null) return pending;
|
||||||
|
if (declined && !force) return Future.value(false);
|
||||||
|
final p = prompt;
|
||||||
|
if (p == null) return Future.value(false);
|
||||||
|
return _pending = p(reason).then((ok) {
|
||||||
|
declined = !ok && !unlocked;
|
||||||
|
return ok;
|
||||||
|
}).whenComplete(() => _pending = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [instance] 가 없으면 (시험 · 마스터 기능을 쓰지 않음) 늘 true
|
||||||
|
static Future<bool> gate() => instance?.ensure() ?? Future.value(true);
|
||||||
|
}
|
||||||
|
|
@ -257,6 +257,9 @@ class AppSettings {
|
||||||
|
|
||||||
/// YouTube 로봇 확인 대응: 쿠키를 가져올 브라우저 (빈 값 = 사용 안 함) 또는 cookies.txt 경로
|
/// YouTube 로봇 확인 대응: 쿠키를 가져올 브라우저 (빈 값 = 사용 안 함) 또는 cookies.txt 경로
|
||||||
String ytCookiesBrowser = internalBrowserCookies; // 기본: 앱 안 브라우저 로그인 사용
|
String ytCookiesBrowser = internalBrowserCookies; // 기본: 앱 안 브라우저 로그인 사용
|
||||||
|
|
||||||
|
/// 53: 앱 안 브라우저 로그인 쿠키를 넘길 사이트 (처음은 모두 - 28)
|
||||||
|
List<String> loginCookieSites = [...loginCookieDomains];
|
||||||
String ytCookiesFile = '';
|
String ytCookiesFile = '';
|
||||||
|
|
||||||
/// 동시에 받는 다운로드 수 (0 = 무제한)
|
/// 동시에 받는 다운로드 수 (0 = 무제한)
|
||||||
|
|
@ -417,6 +420,7 @@ class AppSettings {
|
||||||
/// 40 · 54: 비밀번호를 이 기기의 안전 저장소에 기억 (끄면 저장하지 않고 앱을 켤 때마다 다시 넣음)
|
/// 40 · 54: 비밀번호를 이 기기의 안전 저장소에 기억 (끄면 저장하지 않고 앱을 켤 때마다 다시 넣음)
|
||||||
bool rememberPasswords = true;
|
bool rememberPasswords = true;
|
||||||
|
|
||||||
|
|
||||||
/// 65 (Windows): 탐색기에서 지우면 휴지통으로 (끄면 늘 영구 삭제로 묻는다)
|
/// 65 (Windows): 탐색기에서 지우면 휴지통으로 (끄면 늘 영구 삭제로 묻는다)
|
||||||
bool recycleOnDelete = true;
|
bool recycleOnDelete = true;
|
||||||
|
|
||||||
|
|
@ -529,6 +533,8 @@ class AppSettings {
|
||||||
'ytPreferH264': ytPreferH264,
|
'ytPreferH264': ytPreferH264,
|
||||||
'ytExpandPlaylists': ytExpandPlaylists,
|
'ytExpandPlaylists': ytExpandPlaylists,
|
||||||
'ytCookiesBrowser': ytCookiesBrowser,
|
'ytCookiesBrowser': ytCookiesBrowser,
|
||||||
|
'loginCookieSites': loginCookieSites,
|
||||||
|
'cookieScopeV2': true,
|
||||||
'ytCookiesFile': ytCookiesFile,
|
'ytCookiesFile': ytCookiesFile,
|
||||||
'maxParallelDownloads': maxParallelDownloads,
|
'maxParallelDownloads': maxParallelDownloads,
|
||||||
'maxParallelJobs': maxParallelJobs,
|
'maxParallelJobs': maxParallelJobs,
|
||||||
|
|
@ -585,7 +591,8 @@ class AppSettings {
|
||||||
'recycleOnDelete': recycleOnDelete,
|
'recycleOnDelete': recycleOnDelete,
|
||||||
'tripleTapAction': tripleTapAction,
|
'tripleTapAction': tripleTapAction,
|
||||||
'davExternalOpen': davExternalOpen,
|
'davExternalOpen': davExternalOpen,
|
||||||
'allFilesHintShown': allFilesHintShown,
|
// 116: 예전 판의 표시 ('allFilesHintShown') 는 권한이 없어진 것을 몰라 다시 띄우지 못했다 → 새 이름으로 한 번 더
|
||||||
|
'allFilesHint2': allFilesHintShown,
|
||||||
'subtitleScale': subtitleScale,
|
'subtitleScale': subtitleScale,
|
||||||
'zipComic': zipComic,
|
'zipComic': zipComic,
|
||||||
// 예전 기본값 (만화 보기 켜짐) 은 지시와 반대였으므로 한 번 꺼진 상태 (목록) 로
|
// 예전 기본값 (만화 보기 켜짐) 은 지시와 반대였으므로 한 번 꺼진 상태 (목록) 로
|
||||||
|
|
@ -666,6 +673,9 @@ class AppSettings {
|
||||||
..ytQuality = pick(YtQuality.values, j['ytQuality'], YtQuality.best)
|
..ytQuality = pick(YtQuality.values, j['ytQuality'], YtQuality.best)
|
||||||
..ytExpandPlaylists = j['ytExpandPlaylists'] as bool? ?? true
|
..ytExpandPlaylists = j['ytExpandPlaylists'] as bool? ?? true
|
||||||
..ytCookiesBrowser = j['ytCookiesBrowser'] as String? ?? internalBrowserCookies
|
..ytCookiesBrowser = j['ytCookiesBrowser'] as String? ?? internalBrowserCookies
|
||||||
|
..loginCookieSites = j['loginCookieSites'] is List
|
||||||
|
? [for (final x in j['loginCookieSites'] as List) if (loginCookieDomains.contains(x)) '$x']
|
||||||
|
: [...loginCookieDomains]
|
||||||
..ytCookiesFile = j['ytCookiesFile'] as String? ?? ''
|
..ytCookiesFile = j['ytCookiesFile'] as String? ?? ''
|
||||||
..maxParallelDownloads = (j['maxParallelDownloads'] as num?)?.toInt() ?? 3
|
..maxParallelDownloads = (j['maxParallelDownloads'] as num?)?.toInt() ?? 3
|
||||||
..maxParallelJobs = (j['maxParallelJobs'] as num?)?.toInt() ?? 5
|
..maxParallelJobs = (j['maxParallelJobs'] as num?)?.toInt() ?? 5
|
||||||
|
|
@ -742,7 +752,7 @@ class AppSettings {
|
||||||
..recycleOnDelete = j['recycleOnDelete'] != false
|
..recycleOnDelete = j['recycleOnDelete'] != false
|
||||||
..tripleTapAction = j['tripleTapAction'] == 'none' ? 'none' : 'move'
|
..tripleTapAction = j['tripleTapAction'] == 'none' ? 'none' : 'move'
|
||||||
..davExternalOpen = const ['fetch', 'url'].contains(j['davExternalOpen']) ? j['davExternalOpen'] as String : 'ask'
|
..davExternalOpen = const ['fetch', 'url'].contains(j['davExternalOpen']) ? j['davExternalOpen'] as String : 'ask'
|
||||||
..allFilesHintShown = j['allFilesHintShown'] == true
|
..allFilesHintShown = j['allFilesHint2'] == true
|
||||||
..subtitleScale = ((j['subtitleScale'] as num?)?.toDouble() ?? 1.0).clamp(0.5, 2.5)
|
..subtitleScale = ((j['subtitleScale'] as num?)?.toDouble() ?? 1.0).clamp(0.5, 2.5)
|
||||||
..zipComic = j['zipComic'] == true && j['zipComicV2'] == true
|
..zipComic = j['zipComic'] == true && j['zipComicV2'] == true
|
||||||
..webdavServers = [
|
..webdavServers = [
|
||||||
|
|
@ -826,6 +836,9 @@ class AppSettings {
|
||||||
if (j['explorerV2'] != true && j['explorerLayout'] == 'dual' && s.explorerLayout == 'auto') 'explorerLayout',
|
if (j['explorerV2'] != true && j['explorerLayout'] == 'dual' && s.explorerLayout == 'auto') 'explorerLayout',
|
||||||
if (j['explorerV2'] != true && j['explorerClick'] == 'select' && s.explorerClick == 'open') 'explorerClick',
|
if (j['explorerV2'] != true && j['explorerClick'] == 'select' && s.explorerClick == 'open') 'explorerClick',
|
||||||
if (j['explorerOrientV3'] != true && j['explorerOrientation'] == 'auto') 'explorerOrientation',
|
if (j['explorerOrientV3'] != true && j['explorerOrientation'] == 'auto') 'explorerOrientation',
|
||||||
|
// 53 · 28: 앱 안 브라우저 쿠키를 쓰던 사람에게 넘기는 사이트가 넓어졌음을 한 번 알린다
|
||||||
|
if (j['cookieScopeV2'] != true && j.isNotEmpty && (j['ytCookiesBrowser'] ?? internalBrowserCookies) == internalBrowserCookies)
|
||||||
|
'cookieScope',
|
||||||
if (j['zipComicV2'] != true && j['zipComic'] != false && j.containsKey('imageExts') && !s.zipComic) 'zipComic',
|
if (j['zipComicV2'] != true && j['zipComic'] != false && j.containsKey('imageExts') && !s.zipComic) 'zipComic',
|
||||||
if (j['backgroundV2'] != true && j['runInBackground'] == false && s.runInBackground && Platform.isAndroid)
|
if (j['backgroundV2'] != true && j['runInBackground'] == false && s.runInBackground && Platform.isAndroid)
|
||||||
'background',
|
'background',
|
||||||
|
|
|
||||||
|
|
@ -183,10 +183,21 @@ List<String> ytDlpFormatArgs(YtContainer c, YtQuality q, {bool preferH264 = fals
|
||||||
/// yt-dlp 에 넘기는 로그인 쿠키의 사이트 (YouTube · Google · Instagram · X · 네이버 / 치지직 - 28)
|
/// yt-dlp 에 넘기는 로그인 쿠키의 사이트 (YouTube · Google · Instagram · X · 네이버 / 치지직 - 28)
|
||||||
const loginCookieDomains = ['youtube.com', 'google.com', 'instagram.com', 'x.com', 'twitter.com', 'naver.com'];
|
const loginCookieDomains = ['youtube.com', 'google.com', 'instagram.com', 'x.com', 'twitter.com', 'naver.com'];
|
||||||
|
|
||||||
/// [d] (".x.com" 등) 가 [loginCookieDomains] 중 하나이거나 그 아래인지 (netflix.com 이 x.com 으로 잡히지 않게)
|
/// 53: 앱 안 브라우저가 로그인 쿠키를 yt-dlp 용 파일로 내보낼지 · 어느 사이트만 (환경 설정 > 다운로드 > YouTube 쿠키)
|
||||||
|
class CookieExport {
|
||||||
|
/// 쿠키를 쓰지 않으면 (앱 안 브라우저 로그인 사용이 아니면) 파일을 만들지 않는다
|
||||||
|
static bool enabled = true;
|
||||||
|
static List<String> sites = loginCookieDomains;
|
||||||
|
|
||||||
|
/// 내보낸 파일 이름 (앱 안 브라우저 데이터 폴더)
|
||||||
|
static const fileName = 'cookies_youtube.txt';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [d] (".x.com" 등) 가 내보낼 사이트 ([CookieExport.sites], 처음은 [loginCookieDomains] 모두) 중 하나이거나
|
||||||
|
/// 그 아래인지 (netflix.com 이 x.com 으로 잡히지 않게)
|
||||||
bool isLoginCookieDomain(String d) {
|
bool isLoginCookieDomain(String d) {
|
||||||
final h = (d.startsWith('.') ? d.substring(1) : d).toLowerCase();
|
final h = (d.startsWith('.') ? d.substring(1) : d).toLowerCase();
|
||||||
return loginCookieDomains.any((x) => h == x || h.endsWith('.$x'));
|
return CookieExport.sites.any((x) => h == x || h.endsWith('.$x'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 앱 안 브라우저(내장 Edge) 의 쿠키를 쓰는 값
|
/// 앱 안 브라우저(내장 Edge) 의 쿠키를 쓰는 값
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import 'package:path/path.dart' as p;
|
||||||
import '../l10n/tr.dart';
|
import '../l10n/tr.dart';
|
||||||
import 'playlist.dart' show naturalCompare;
|
import 'playlist.dart' show naturalCompare;
|
||||||
import 'vfs.dart';
|
import 'vfs.dart';
|
||||||
import 'webdav.dart' show DavException;
|
import 'webdav.dart' show DavException, DavLockedException;
|
||||||
|
|
||||||
/// 파일 탐색기 (X-plore 참고) 의 화면과 상관없는 부분: 폴더 읽기 · 정렬 · 복사 / 이동 / 삭제 · 찾기.
|
/// 파일 탐색기 (X-plore 참고) 의 화면과 상관없는 부분: 폴더 읽기 · 정렬 · 복사 / 이동 / 삭제 · 찾기.
|
||||||
|
|
||||||
|
|
@ -202,6 +202,9 @@ class SourceUnreadableException implements Exception {
|
||||||
final bool empty;
|
final bool empty;
|
||||||
const SourceUnreadableException(this.path, {this.cause, this.empty = false});
|
const SourceUnreadableException(this.path, {this.cause, this.empty = false});
|
||||||
|
|
||||||
|
/// 128: 마스터 비밀번호를 기다리는 중 (읽지 못한 것이 아님)
|
||||||
|
bool get locked => cause is DavLockedException;
|
||||||
|
|
||||||
/// 원인을 짧게 (예외 원문 대신 - 68)
|
/// 원인을 짧게 (예외 원문 대신 - 68)
|
||||||
String get detail {
|
String get detail {
|
||||||
final c = cause;
|
final c = cause;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/// 124: 저장된 비밀번호 (WebDAV · OpenSubtitles · API 키) 를 쓰기 전에 지나는 문.
|
||||||
|
/// 마스터 비밀번호를 정해 두었으면 앱이 [check] 에 "필요하면 묻기" 를 넣는다 (main). 없으면 늘 통과.
|
||||||
|
class SecretGate {
|
||||||
|
/// [force]: 사용자가 직접 연 것 (마스터 창을 한 번 취소했어도 다시 묻는다). 아니면 자동 · 배경 작업
|
||||||
|
static Future<bool> Function(bool force)? check;
|
||||||
|
|
||||||
|
/// 써도 되면 true
|
||||||
|
static Future<bool> pass({bool force = false}) => check?.call(force) ?? Future.value(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 마스터 비밀번호를 넣지 않아 막혔을 때의 안내
|
||||||
|
const secretGateMessage = '마스터 비밀번호를 넣어야 저장된 비밀번호를 쓸 수 있습니다';
|
||||||
|
|
@ -5,6 +5,7 @@ import 'dart:io';
|
||||||
import 'package:xml/xml.dart';
|
import 'package:xml/xml.dart';
|
||||||
|
|
||||||
import '../l10n/tr.dart';
|
import '../l10n/tr.dart';
|
||||||
|
import 'secret_gate.dart';
|
||||||
|
|
||||||
/// WebDAV 서버 설정 (환경 설정 > 파일 탐색기 > WebDAV). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
|
/// WebDAV 서버 설정 (환경 설정 > 파일 탐색기 > WebDAV). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
|
||||||
class DavServer {
|
class DavServer {
|
||||||
|
|
@ -64,6 +65,11 @@ class DavItem {
|
||||||
const DavItem(this.rel, {required this.isDir, this.size = 0, required this.modified});
|
const DavItem(this.rel, {required this.isDir, this.size = 0, required this.modified});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 124 · 128: 마스터 비밀번호를 넣지 않아 저장된 비밀번호를 쓰지 못함 (원본을 못 읽은 것이 아니라 기다리는 중)
|
||||||
|
class DavLockedException extends DavException {
|
||||||
|
const DavLockedException(super.message);
|
||||||
|
}
|
||||||
|
|
||||||
class DavException implements Exception {
|
class DavException implements Exception {
|
||||||
final String message;
|
final String message;
|
||||||
final int? status;
|
final int? status;
|
||||||
|
|
@ -109,6 +115,8 @@ class DavClient {
|
||||||
: 'Basic ${base64Encode(utf8.encode('${server.user}:${server.password}'))}';
|
: 'Basic ${base64Encode(utf8.encode('${server.user}:${server.password}'))}';
|
||||||
|
|
||||||
Future<HttpClientRequest> _open(String method, Uri uri) async {
|
Future<HttpClientRequest> _open(String method, Uri uri) async {
|
||||||
|
// 124: 저장된 비밀번호로 접속하기 전에 (마스터 비밀번호를 정해 두었으면 묻는다)
|
||||||
|
if (server.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage));
|
||||||
final req = await _http.openUrl(method, uri);
|
final req = await _http.openUrl(method, uri);
|
||||||
final a = _auth;
|
final a = _auth;
|
||||||
if (a != null) req.headers.set(HttpHeaders.authorizationHeader, a);
|
if (a != null) req.headers.set(HttpHeaders.authorizationHeader, a);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import 'ui/settings_problem.dart';
|
||||||
import 'ui/exit_guard.dart' show confirmStopCopies;
|
import 'ui/exit_guard.dart' show confirmStopCopies;
|
||||||
import 'app/copy_center.dart';
|
import 'app/copy_center.dart';
|
||||||
import 'ui/migration_notice.dart';
|
import 'ui/migration_notice.dart';
|
||||||
|
import 'platform/windows/data_dir_override.dart';
|
||||||
|
import 'ui/master_prompt.dart';
|
||||||
import 'ui/secret_issue.dart';
|
import 'ui/secret_issue.dart';
|
||||||
import 'ui/sync_alert.dart';
|
import 'ui/sync_alert.dart';
|
||||||
import 'app/bookmarks_controller.dart';
|
import 'app/bookmarks_controller.dart';
|
||||||
|
|
@ -70,13 +72,15 @@ Future<String> _prepare() async {
|
||||||
|
|
||||||
Future<void> main(List<String> args) async {
|
Future<void> main(List<String> args) async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
applyDataDirOverride(); // 시험판: 사용자 데이터와 다른 폴더 (JJ_MKVMAKER_DATA)
|
||||||
final dataDir = await _prepare();
|
final dataDir = await _prepare();
|
||||||
if (Platform.isAndroid) return runAndroid(dataDir);
|
if (Platform.isAndroid) return runAndroid(dataDir);
|
||||||
|
|
||||||
// 이미 실행 중이면 인수(탐색기에서 고른 파일)를 넘기고 끝낸다
|
// 이미 실행 중이면 인수(탐색기에서 고른 파일)를 넘기고 끝낸다
|
||||||
final request = LaunchRequest.parse(args);
|
final request = LaunchRequest.parse(args);
|
||||||
final newWindow = wantsNewPlayerWindow(request, await SettingsStore().load());
|
final newWindow = wantsNewPlayerWindow(request, await SettingsStore().load());
|
||||||
final instance = SingleInstance();
|
// 시험판은 사용자가 켜 둔 앱에 넘기지 않고 따로 뜬다
|
||||||
|
final instance = SingleInstance(port: dataDirOverride == null ? 47821 : 47822);
|
||||||
if (!await instance.claim(request, waitForExit: args.contains('--restart'), forward: !newWindow)) {
|
if (!await instance.claim(request, waitForExit: args.contains('--restart'), forward: !newWindow)) {
|
||||||
// 설정이 "새 창에서" 이고 이미 켜져 있으면: 재생만 하는 창으로 따로 뜬다
|
// 설정이 "새 창에서" 이고 이미 켜져 있으면: 재생만 하는 창으로 따로 뜬다
|
||||||
if (newWindow) return runSecondWindow(request.files.where(isVideoFile).toList(), dataDir);
|
if (newWindow) return runSecondWindow(request.files.where(isVideoFile).toList(), dataDir);
|
||||||
|
|
@ -108,6 +112,7 @@ Future<void> main(List<String> args) async {
|
||||||
ComponentStore.dataDirectory = dataDir;
|
ComponentStore.dataDirectory = dataDir;
|
||||||
VersionSnapshot.instance = VersionSnapshot(dataDir);
|
VersionSnapshot.instance = VersionSnapshot(dataDir);
|
||||||
await controller.init();
|
await controller.init();
|
||||||
|
await setupMasterLock(controller); // 124: 저장된 비밀번호를 쓰기 전에 (실시간 동기화보다 먼저)
|
||||||
// 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함
|
// 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함
|
||||||
await findBundledRsync(); // 앱에 들어 있는 rsync
|
await findBundledRsync(); // 앱에 들어 있는 rsync
|
||||||
final live = LiveSync(controller)..start(hold: controller.settings.liveSyncOnStart != 'auto');
|
final live = LiveSync(controller)..start(hold: controller.settings.liveSyncOnStart != 'auto');
|
||||||
|
|
@ -124,6 +129,7 @@ Future<void> main(List<String> args) async {
|
||||||
);
|
);
|
||||||
final navigatorKey = GlobalKey<NavigatorState>();
|
final navigatorKey = GlobalKey<NavigatorState>();
|
||||||
final messengerKey = GlobalKey<ScaffoldMessengerState>();
|
final messengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||||
|
attachMasterPrompt(navigatorKey);
|
||||||
|
|
||||||
final memory = WindowMemory(p.join(dataDir, 'window.json'), 'main');
|
final memory = WindowMemory(p.join(dataDir, 'window.json'), 'main');
|
||||||
|
|
||||||
|
|
@ -335,6 +341,9 @@ Future<void> main(List<String> args) async {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
final ctx = navigatorKey.currentContext;
|
final ctx = navigatorKey.currentContext;
|
||||||
if (ctx == null) return;
|
if (ctx == null) return;
|
||||||
|
// 124: "처음 시작 시" 면 마스터 비밀번호
|
||||||
|
await askMasterAtStartup(ctx, controller);
|
||||||
|
if (!ctx.mounted) return;
|
||||||
if (crashed != null) {
|
if (crashed != null) {
|
||||||
messengerKey.currentState?.showSnackBar(SnackBar(
|
messengerKey.currentState?.showSnackBar(SnackBar(
|
||||||
duration: const Duration(seconds: 10),
|
duration: const Duration(seconds: 10),
|
||||||
|
|
@ -465,6 +474,7 @@ Future<void> runAndroid(String dataDir) async {
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
VersionSnapshot.instance = VersionSnapshot(dataDir, sharedDir: sharedSnapshots);
|
VersionSnapshot.instance = VersionSnapshot(dataDir, sharedDir: sharedSnapshots);
|
||||||
await controller.init();
|
await controller.init();
|
||||||
|
await setupMasterLock(controller); // 124: 저장된 비밀번호를 쓰기 전에 (실시간 동기화보다 먼저)
|
||||||
// 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함
|
// 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함
|
||||||
await findBundledRsync(); // 앱에 들어 있는 rsync
|
await findBundledRsync(); // 앱에 들어 있는 rsync
|
||||||
final live = LiveSync(controller)..start(hold: controller.settings.liveSyncOnStart != 'auto');
|
final live = LiveSync(controller)..start(hold: controller.settings.liveSyncOnStart != 'auto');
|
||||||
|
|
@ -478,6 +488,7 @@ Future<void> runAndroid(String dataDir) async {
|
||||||
final navigatorKey = GlobalKey<NavigatorState>();
|
final navigatorKey = GlobalKey<NavigatorState>();
|
||||||
final messengerKey = GlobalKey<ScaffoldMessengerState>();
|
final messengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||||
AndroidStorageService.navigatorKey = navigatorKey;
|
AndroidStorageService.navigatorKey = navigatorKey;
|
||||||
|
attachMasterPrompt(navigatorKey);
|
||||||
|
|
||||||
// 다운로드 (앱 안 브라우저의 [다운로드] · 다운로드 목록): 앱에 넣은 yt-dlp · aria2c. 클립보드 감시는 하지 않는다.
|
// 다운로드 (앱 안 브라우저의 [다운로드] · 다운로드 목록): 앱에 넣은 yt-dlp · aria2c. 클립보드 감시는 하지 않는다.
|
||||||
AndroidDownloadTools.log = controller.note;
|
AndroidDownloadTools.log = controller.note;
|
||||||
|
|
@ -557,6 +568,9 @@ Future<void> runAndroid(String dataDir) async {
|
||||||
if (ctxA != null && ctxA.mounted && await allFilesGuideDue(controller, hasAccess: hadAccess) && ctxA.mounted) {
|
if (ctxA != null && ctxA.mounted && await allFilesGuideDue(controller, hasAccess: hadAccess) && ctxA.mounted) {
|
||||||
await showAllFilesGuide(ctxA, AndroidAccess.request);
|
await showAllFilesGuide(ctxA, AndroidAccess.request);
|
||||||
}
|
}
|
||||||
|
// 124: "처음 시작 시" 면 마스터 비밀번호
|
||||||
|
final ctxM = navigatorKey.currentContext;
|
||||||
|
if (ctxM != null && ctxM.mounted) await askMasterAtStartup(ctxM, controller);
|
||||||
final home = AppNavButtons.homeId(controller.settings);
|
final home = AppNavButtons.homeId(controller.settings);
|
||||||
final nav0 = navigatorKey.currentState;
|
final nav0 = navigatorKey.currentState;
|
||||||
if (home != 'mkv' && nav0 != null) {
|
if (home != 'mkv' && nav0 != null) {
|
||||||
|
|
@ -628,8 +642,12 @@ Future<void> runSecondWindow(List<String> files, String dataDir) async {
|
||||||
// 화면 언어 (환경 설정 > 화면 언어)
|
// 화면 언어 (환경 설정 > 화면 언어)
|
||||||
i18n.init(controller, dataDir);
|
i18n.init(controller, dataDir);
|
||||||
await i18n.apply(controller.settings.uiLanguage, save: false);
|
await i18n.apply(controller.settings.uiLanguage, save: false);
|
||||||
controller.settings = await SettingsStore().load();
|
final store = SettingsStore();
|
||||||
|
controller.settings = await store.load();
|
||||||
final navigatorKey = GlobalKey<NavigatorState>();
|
final navigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
// 124: 이 창도 저장된 비밀번호 (WebDAV 재생) 를 쓰기 전에 마스터를 묻는다 (풀린 상태는 창마다)
|
||||||
|
await setupMasterLock(controller, secrets: store.secrets);
|
||||||
|
attachMasterPrompt(navigatorKey);
|
||||||
runApp(JjMkvMakerApp(controller: controller, navigatorKey: navigatorKey, onExit: endProcessNow));
|
runApp(JjMkvMakerApp(controller: controller, navigatorKey: navigatorKey, onExit: endProcessNow));
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
await controller.shareVideoList(p.join(dataDir, 'videos.json'));
|
await controller.shareVideoList(p.join(dataDir, 'videos.json'));
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:media_kit/media_kit.dart' as mk;
|
||||||
import 'package:media_kit_video/media_kit_video.dart';
|
import 'package:media_kit_video/media_kit_video.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
import '../../core/secret_gate.dart';
|
||||||
import '../../core/vfs.dart';
|
import '../../core/vfs.dart';
|
||||||
import '../../services/media_player.dart';
|
import '../../services/media_player.dart';
|
||||||
import '../../l10n/tr.dart';
|
import '../../l10n/tr.dart';
|
||||||
|
|
@ -127,15 +128,24 @@ class MediaKitFullPlayer implements MediaPlayer {
|
||||||
_emit();
|
_emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 플레이어가 처음 가진 인증서 확인 값 (56: 자체 서명 서버를 튼 뒤 다른 것을 틀면 원래대로)
|
||||||
|
String? _tlsDefault;
|
||||||
|
|
||||||
/// WebDAV 는 받지 않고 바로 스트리밍 (주소 + 인증 헤더, 자체 서명 인증서면 확인 안 함)
|
/// WebDAV 는 받지 않고 바로 스트리밍 (주소 + 인증 헤더, 자체 서명 인증서면 확인 안 함)
|
||||||
Future<mk.Media> _media(String f) async {
|
Future<mk.Media> _media(String f) async {
|
||||||
if (!isDav(f)) return mk.Media(f);
|
final native = _p.platform;
|
||||||
final s = davStream(f);
|
final dav = isDav(f) ? davStream(f) : null;
|
||||||
if (s.insecure) {
|
if (native is mk.NativePlayer) {
|
||||||
final native = _p.platform;
|
_tlsDefault ??= await native.getProperty('tls-verify');
|
||||||
if (native is mk.NativePlayer) await native.setProperty('tls-verify', 'no');
|
// 56: "인증서 확인 안 함" 서버의 것만 확인을 끄고, 그 밖은 원래 값으로 (한 번 끈 것이 계속 남지 않게)
|
||||||
|
final want = dav != null && dav.insecure ? 'no' : _tlsDefault!;
|
||||||
|
await native.setProperty('tls-verify', want);
|
||||||
}
|
}
|
||||||
return mk.Media(s.url, httpHeaders: s.headers);
|
if (dav == null) return mk.Media(f);
|
||||||
|
// 124: 저장된 비밀번호로 여는 것이면 (마스터 비밀번호를 정해 두었으면 묻는다)
|
||||||
|
// 사용자가 직접 튼 것이니 한 번 취소했어도 다시 묻는다
|
||||||
|
if (dav.headers.isNotEmpty && !await SecretGate.pass(force: true)) throw Exception(tr(secretGateMessage));
|
||||||
|
return mk.Media(dav.url, httpHeaders: dav.headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import '../../core/languages.dart';
|
||||||
import '../../core/subtitle_search.dart';
|
import '../../core/subtitle_search.dart';
|
||||||
import '../../services/subtitle_provider.dart';
|
import '../../services/subtitle_provider.dart';
|
||||||
import '../../l10n/tr.dart';
|
import '../../l10n/tr.dart';
|
||||||
|
import '../../core/secret_gate.dart';
|
||||||
|
|
||||||
/// OpenSubtitles.com REST API (v1). Windows·Android 공용.
|
/// OpenSubtitles.com REST API (v1). Windows·Android 공용.
|
||||||
///
|
///
|
||||||
|
|
@ -45,6 +46,8 @@ class OpenSubtitlesProvider implements SubtitleProvider {
|
||||||
|
|
||||||
Future<Map<String, dynamic>> _request(String method, String path,
|
Future<Map<String, dynamic>> _request(String method, String path,
|
||||||
{Map<String, String>? query, Object? body, bool auth = false}) async {
|
{Map<String, String>? query, Object? body, bool auth = false}) async {
|
||||||
|
// 124: 저장된 API 키 · 비밀번호를 쓰기 전에
|
||||||
|
if (!await SecretGate.pass()) throw SubtitleProviderException(tr(secretGateMessage));
|
||||||
final uri = Uri.parse('$_api$path').replace(queryParameters: query);
|
final uri = Uri.parse('$_api$path').replace(queryParameters: query);
|
||||||
final req = await _http.openUrl(method, uri);
|
final req = await _http.openUrl(method, uri);
|
||||||
req.headers
|
req.headers
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||||
|
|
||||||
|
/// 시험판 (Windows): 환경 변수 JJ_MKVMAKER_DATA 가 있으면 설정 · 비밀번호 (secrets.dat) · 즐겨찾기 등을
|
||||||
|
/// 사용자의 실제 앱 폴더 대신 그 폴더에 둔다. 사용자 PC 에서 시험해도 사용자 데이터를 건드리지 않게.
|
||||||
|
/// 설정이 없으면 (보통의 실행) 아무것도 바꾸지 않는다.
|
||||||
|
String? get dataDirOverride {
|
||||||
|
if (!Platform.isWindows) return null;
|
||||||
|
final v = Platform.environment['JJ_MKVMAKER_DATA']?.trim() ?? '';
|
||||||
|
return v.isEmpty ? null : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [dataDirOverride] 가 있으면 앱 데이터 폴더만 그것으로 (나머지는 원래 것)
|
||||||
|
void applyDataDirOverride() {
|
||||||
|
final dir = dataDirOverride;
|
||||||
|
if (dir == null) return;
|
||||||
|
Directory(dir).createSync(recursive: true);
|
||||||
|
PathProviderPlatform.instance = _OverridePaths(PathProviderPlatform.instance, dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OverridePaths extends PathProviderPlatform {
|
||||||
|
_OverridePaths(this._base, this._dir);
|
||||||
|
final PathProviderPlatform _base;
|
||||||
|
final String _dir;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getApplicationSupportPath() async => _dir;
|
||||||
|
@override
|
||||||
|
Future<String?> getTemporaryPath() => _base.getTemporaryPath();
|
||||||
|
@override
|
||||||
|
Future<String?> getLibraryPath() => _base.getLibraryPath();
|
||||||
|
@override
|
||||||
|
Future<String?> getApplicationDocumentsPath() => _base.getApplicationDocumentsPath();
|
||||||
|
@override
|
||||||
|
Future<String?> getApplicationCachePath() => _base.getApplicationCachePath();
|
||||||
|
@override
|
||||||
|
Future<String?> getExternalStoragePath() => _base.getExternalStoragePath();
|
||||||
|
@override
|
||||||
|
Future<List<String>?> getExternalCachePaths() => _base.getExternalCachePaths();
|
||||||
|
@override
|
||||||
|
Future<List<String>?> getExternalStoragePaths({StorageDirectory? type}) => _base.getExternalStoragePaths(type: type);
|
||||||
|
@override
|
||||||
|
Future<String?> getDownloadsPath() => _base.getDownloadsPath();
|
||||||
|
}
|
||||||
|
|
@ -56,6 +56,62 @@ int? _binCount(String path) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 휴지통 정보 파일 ($I…) 에 적힌 원래 경로. Windows 10 이상 (판 2: 길이 + 경로) · 예전 (판 1: 260자 고정). 모르면 null
|
||||||
|
String? recycledInfoPath(List<int> bytes) {
|
||||||
|
if (bytes.length < 28) return null;
|
||||||
|
final version = bytes[0] | bytes[1] << 8;
|
||||||
|
int start, end;
|
||||||
|
if (version == 2) {
|
||||||
|
final n = bytes[24] | bytes[25] << 8 | bytes[26] << 16 | bytes[27] << 24;
|
||||||
|
start = 28;
|
||||||
|
end = start + n * 2;
|
||||||
|
} else if (version == 1) {
|
||||||
|
start = 24;
|
||||||
|
end = 24 + 520;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (end > bytes.length) end = bytes.length - bytes.length % 2;
|
||||||
|
final units = <int>[];
|
||||||
|
for (var i = start; i + 1 < end; i += 2) {
|
||||||
|
final u = bytes[i] | bytes[i + 1] << 8;
|
||||||
|
if (u == 0) break;
|
||||||
|
units.add(u);
|
||||||
|
}
|
||||||
|
return String.fromCharCodes(units);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 그 드라이브 휴지통에 [original] 의 정보 파일이 [since] 뒤에 생겼는지. 휴지통 폴더를 읽을 수 없으면 null
|
||||||
|
bool? recycledInfoExists(String original, {required DateTime since}) {
|
||||||
|
final m = RegExp(r'^([a-zA-Z]):').firstMatch(original);
|
||||||
|
if (m == null) return null;
|
||||||
|
final bin = Directory('${m[1]}:\\\$Recycle.Bin');
|
||||||
|
var readable = false;
|
||||||
|
try {
|
||||||
|
for (final user in bin.listSync(followLinks: false).whereType<Directory>()) {
|
||||||
|
List<FileSystemEntity> items;
|
||||||
|
try {
|
||||||
|
items = user.listSync(followLinks: false); // 다른 사용자의 휴지통은 읽을 수 없다
|
||||||
|
} catch (_) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
readable = true;
|
||||||
|
for (final f in items.whereType<File>()) {
|
||||||
|
final name = f.uri.pathSegments.last;
|
||||||
|
if (!name.startsWith(r'$I')) continue;
|
||||||
|
try {
|
||||||
|
if (f.lastModifiedSync().isBefore(since)) continue;
|
||||||
|
final p = recycledInfoPath(f.readAsBytesSync());
|
||||||
|
if (p != null && p.toLowerCase() == original.toLowerCase()) return true;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return readable ? false : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// SHFileOperation 의 오류 번호를 사람이 읽을 말로 (98)
|
/// SHFileOperation 의 오류 번호를 사람이 읽을 말로 (98)
|
||||||
String recycleErrorText(int code) => switch (code) {
|
String recycleErrorText(int code) => switch (code) {
|
||||||
0x02 || 0x03 => tr('찾을 수 없습니다 (이미 지워졌거나 옮겨졌습니다)'),
|
0x02 || 0x03 => tr('찾을 수 없습니다 (이미 지워졌거나 옮겨졌습니다)'),
|
||||||
|
|
@ -77,6 +133,8 @@ RecycleResult moveToRecycleBin(String path) {
|
||||||
final shell32 = DynamicLibrary.open('shell32.dll');
|
final shell32 = DynamicLibrary.open('shell32.dll');
|
||||||
final op = shell32.lookupFunction<Int32 Function(Pointer<_ShFileOp>), int Function(Pointer<_ShFileOp>)>('SHFileOperationW');
|
final op = shell32.lookupFunction<Int32 Function(Pointer<_ShFileOp>), int Function(Pointer<_ShFileOp>)>('SHFileOperationW');
|
||||||
final before = _binCount(path);
|
final before = _binCount(path);
|
||||||
|
final full = File(path).absolute.path;
|
||||||
|
final started = DateTime.now().subtract(const Duration(seconds: 2));
|
||||||
// pFrom: 끝에 NUL 두 개 (여러 경로 목록 형식)
|
// pFrom: 끝에 NUL 두 개 (여러 경로 목록 형식)
|
||||||
final units = [...File(path).absolute.path.codeUnits, 0, 0];
|
final units = [...File(path).absolute.path.codeUnits, 0, 0];
|
||||||
final from = calloc<Uint16>(units.length);
|
final from = calloc<Uint16>(units.length);
|
||||||
|
|
@ -101,11 +159,16 @@ RecycleResult moveToRecycleBin(String path) {
|
||||||
if (FileSystemEntity.typeSync(path, followLinks: false) != FileSystemEntityType.notFound) {
|
if (FileSystemEntity.typeSync(path, followLinks: false) != FileSystemEntityType.notFound) {
|
||||||
return RecycleResult.cancelled;
|
return RecycleResult.cancelled;
|
||||||
}
|
}
|
||||||
// 휴지통 항목 수가 늘었는지로 실제로 들어갔는지 본다 (수가 늦게 바뀔 수 있어 몇 번 다시 본다)
|
// 휴지통 안에 이 파일의 정보 ($I…, 원래 경로가 적힘) 가 새로 생겼는지로 본다 (늦게 생길 수 있어 몇 번 다시 본다).
|
||||||
if (before == null) return RecycleResult.recycled;
|
// 휴지통 폴더를 읽을 수 없으면 항목 수가 늘었는지로.
|
||||||
for (var k = 0; k < 5; k++) {
|
for (var k = 0; k < 5; k++) {
|
||||||
final after = _binCount(path);
|
final found = recycledInfoExists(full, since: started);
|
||||||
if (after == null || after > before) return RecycleResult.recycled;
|
if (found == true) return RecycleResult.recycled;
|
||||||
|
if (found == null) {
|
||||||
|
if (before == null) return RecycleResult.recycled;
|
||||||
|
final after = _binCount(path);
|
||||||
|
if (after == null || after > before) return RecycleResult.recycled;
|
||||||
|
}
|
||||||
sleep(const Duration(milliseconds: 100));
|
sleep(const Duration(milliseconds: 100));
|
||||||
}
|
}
|
||||||
return RecycleResult.deletedPermanently;
|
return RecycleResult.deletedPermanently;
|
||||||
|
|
|
||||||
|
|
@ -52,19 +52,29 @@ class _DpapiSecretStore implements SecretStore {
|
||||||
|
|
||||||
Map<String, String>? _cache;
|
Map<String, String>? _cache;
|
||||||
|
|
||||||
|
/// 읽은 파일의 고친 시각 · 크기 (130: 다른 창 · 프로세스가 고쳤으면 다시 읽는다)
|
||||||
|
(DateTime, int)? _stamp;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String? lostCopy;
|
String? lostCopy;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Map<String, String>> readAll() async => Map.of(await _load());
|
Future<Map<String, String>> readAll() async => Map.of(await _load());
|
||||||
|
|
||||||
|
/// 파일이 읽은 뒤로 바뀌지 않았으면 기억해 둔 것, 바뀌었으면 (두 번째 재생 창 · 다른 실행이 씀) 다시 읽는다
|
||||||
Future<Map<String, String>> _load() async {
|
Future<Map<String, String>> _load() async {
|
||||||
if (_cache != null) return _cache!;
|
|
||||||
final f = await _file();
|
final f = await _file();
|
||||||
if (!await f.exists()) return _cache = {};
|
if (!await f.exists()) {
|
||||||
|
_stamp = null;
|
||||||
|
return _cache = {};
|
||||||
|
}
|
||||||
|
final st = await f.stat();
|
||||||
|
final stamp = (st.modified, st.size);
|
||||||
|
if (_cache != null && _stamp == stamp) return _cache!;
|
||||||
final bytes = await f.readAsBytes();
|
final bytes = await f.readAsBytes();
|
||||||
try {
|
try {
|
||||||
final j = jsonDecode(utf8.decode(_dpapi(bytes, protect: false))) as Map;
|
final j = jsonDecode(utf8.decode(_dpapi(bytes, protect: false))) as Map;
|
||||||
|
_stamp = stamp;
|
||||||
return _cache = {for (final e in j.entries) '${e.key}': '${e.value}'};
|
return _cache = {for (final e in j.entries) '${e.key}': '${e.value}'};
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// 풀 수 없음 (다른 PC · 다른 사용자에서 복사해 옴 등): 지우지 않고 보관한 뒤 새로 시작
|
// 풀 수 없음 (다른 PC · 다른 사용자에서 복사해 옴 등): 지우지 않고 보관한 뒤 새로 시작
|
||||||
|
|
@ -87,9 +97,12 @@ class _DpapiSecretStore implements SecretStore {
|
||||||
final tmp = File('${f.path}.tmp');
|
final tmp = File('${f.path}.tmp');
|
||||||
await tmp.writeAsBytes(_dpapi(Uint8List.fromList(utf8.encode(jsonEncode(m))), protect: true), flush: true);
|
await tmp.writeAsBytes(_dpapi(Uint8List.fromList(utf8.encode(jsonEncode(m))), protect: true), flush: true);
|
||||||
await tmp.rename(f.path);
|
await tmp.rename(f.path);
|
||||||
|
final st = await f.stat();
|
||||||
|
_stamp = (st.modified, st.size);
|
||||||
_cache = m;
|
_cache = m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 130: 쓰기 · 지우기 바로 전에 파일을 다시 읽어 (다른 창이 그사이 바꾼 것과) 합친다
|
||||||
@override
|
@override
|
||||||
Future<void> write(String key, String value) async => _save({...await _load(), key: value});
|
Future<void> write(String key, String value) async => _save({...await _load(), key: value});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import '../app/app_controller.dart';
|
||||||
import '../app/bookmarks_controller.dart';
|
import '../app/bookmarks_controller.dart';
|
||||||
import '../app/download_manager.dart';
|
import '../app/download_manager.dart';
|
||||||
import '../core/bookmarks.dart';
|
import '../core/bookmarks.dart';
|
||||||
import '../core/download_detect.dart' show CookieRecord, isLoginCookieDomain, toNetscapeCookies;
|
import '../core/download_detect.dart' show CookieExport, CookieRecord, internalBrowserCookies, isLoginCookieDomain, toNetscapeCookies;
|
||||||
import '../core/web_address.dart';
|
import '../core/web_address.dart';
|
||||||
import '../core/web_translate.dart';
|
import '../core/web_translate.dart';
|
||||||
import '../app/i18n_controller.dart' show I18nController;
|
import '../app/i18n_controller.dart' show I18nController;
|
||||||
|
|
@ -22,6 +22,7 @@ import '../platform/windows/com_guard.dart';
|
||||||
import '../services/downloader.dart' show DownloadState;
|
import '../services/downloader.dart' show DownloadState;
|
||||||
import 'app_actions.dart';
|
import 'app_actions.dart';
|
||||||
import 'bookmark_ui.dart';
|
import 'bookmark_ui.dart';
|
||||||
|
import 'confirm.dart';
|
||||||
import 'downloads_page.dart';
|
import 'downloads_page.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
import 'work_panel.dart';
|
import 'work_panel.dart';
|
||||||
|
|
@ -44,6 +45,9 @@ abstract class WebNav {
|
||||||
/// 페이지에서 스크립트 실행 (YouTube 광고 건너뛰기 등)
|
/// 페이지에서 스크립트 실행 (YouTube 광고 건너뛰기 등)
|
||||||
Future<void> runScript(String js);
|
Future<void> runScript(String js);
|
||||||
|
|
||||||
|
/// 53: 쿠키 · 사이트 데이터 · 캐시 · 방문 기록 (뒤로 · 앞으로 목록) 지우기 - 엔진이 지원하는 것만
|
||||||
|
Future<void> clearData();
|
||||||
|
|
||||||
/// 페이지에서 스크립트를 실행하고 결과를 받는다 (페이지 번역). 실패하면 null.
|
/// 페이지에서 스크립트를 실행하고 결과를 받는다 (페이지 번역). 실패하면 null.
|
||||||
Future<Object?> evaluate(String js);
|
Future<Object?> evaluate(String js);
|
||||||
|
|
||||||
|
|
@ -198,6 +202,7 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
|
||||||
|
|
||||||
void _onSettings() {
|
void _onSettings() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
_applyCookieExport();
|
||||||
final s = widget.c.settings;
|
final s = widget.c.settings;
|
||||||
if (s.webJavaScript != _lastJs) {
|
if (s.webJavaScript != _lastJs) {
|
||||||
_lastJs = s.webJavaScript;
|
_lastJs = s.webJavaScript;
|
||||||
|
|
@ -276,10 +281,37 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
|
||||||
_lastTranslate = s.webTranslate;
|
_lastTranslate = s.webTranslate;
|
||||||
_lastJs = s.webJavaScript;
|
_lastJs = s.webJavaScript;
|
||||||
_lastLang = s.uiLanguage;
|
_lastLang = s.uiLanguage;
|
||||||
|
_applyCookieExport();
|
||||||
widget.c.addListener(_onSettings);
|
widget.c.addListener(_onSettings);
|
||||||
if (_trOn) _translateNow();
|
if (_trOn) _translateNow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 53: 쿠키를 쓰도록 정했을 때만 · 고른 사이트만 내보낸다
|
||||||
|
void _applyCookieExport() {
|
||||||
|
final s = widget.c.settings;
|
||||||
|
CookieExport.enabled = s.ytCookiesBrowser == internalBrowserCookies;
|
||||||
|
CookieExport.sites = s.loginCookieSites;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 53: 쿠키 · 방문 기록 지우기 (확인 뒤). 다운로드용으로 내보낸 쿠키 파일도 지운다
|
||||||
|
Future<void> _clearData() async {
|
||||||
|
final ok = await confirmAction(
|
||||||
|
context,
|
||||||
|
title: tr('쿠키 · 방문 기록 지우기'),
|
||||||
|
body: tr('앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. '
|
||||||
|
'사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.'),
|
||||||
|
ok: tr('지우기'),
|
||||||
|
);
|
||||||
|
if (!ok || !mounted) return;
|
||||||
|
try {
|
||||||
|
await _nav?.clearData();
|
||||||
|
} catch (_) {}
|
||||||
|
await deleteExportedCookies(widget.c.settings.webViewDataDir);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('쿠키 · 방문 기록을 지웠습니다.'))));
|
||||||
|
unawaited(_nav?.reload());
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
|
|
@ -610,6 +642,7 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
if (!compact) btn(Icons.open_in_new, tr('외부 브라우저로 열기'), () => _openExternal(_url)),
|
if (!compact) btn(Icons.open_in_new, tr('외부 브라우저로 열기'), () => _openExternal(_url)),
|
||||||
|
if (!compact) btn(Icons.cookie_outlined, tr('쿠키 · 방문 기록 지우기'), _clearData),
|
||||||
if (widget.c.busy && !_work && !compact)
|
if (widget.c.busy && !_work && !compact)
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: _toggleWork,
|
onTap: _toggleWork,
|
||||||
|
|
@ -634,6 +667,7 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
|
||||||
'translate' => _setTranslate(!_trOn, force: true),
|
'translate' => _setTranslate(!_trOn, force: true),
|
||||||
'external' => _openExternal(_url),
|
'external' => _openExternal(_url),
|
||||||
'work' => _toggleWork(),
|
'work' => _toggleWork(),
|
||||||
|
'clear' => _clearData(),
|
||||||
_ => setState(() => _panel = !_panel),
|
_ => setState(() => _panel = !_panel),
|
||||||
},
|
},
|
||||||
itemBuilder: (_) => [
|
itemBuilder: (_) => [
|
||||||
|
|
@ -653,6 +687,9 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'external',
|
value: 'external',
|
||||||
child: ListTile(dense: true, leading: const Icon(Icons.open_in_new), title: Text(tr('외부 브라우저로 열기')))),
|
child: ListTile(dense: true, leading: const Icon(Icons.open_in_new), title: Text(tr('외부 브라우저로 열기')))),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'clear',
|
||||||
|
child: ListTile(dense: true, leading: const Icon(Icons.cookie_outlined), title: Text(tr('쿠키 · 방문 기록 지우기')))),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
@ -661,6 +698,17 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 53: 다운로드용으로 내보낸 로그인 쿠키 파일 (평문) 을 지운다
|
||||||
|
Future<void> deleteExportedCookies(String dataDir) async {
|
||||||
|
if (dataDir.isEmpty) return;
|
||||||
|
for (final name in [CookieExport.fileName, '${CookieExport.fileName}.tmp']) {
|
||||||
|
try {
|
||||||
|
final f = File('$dataDir${Platform.pathSeparator}$name');
|
||||||
|
if (await f.exists()) await f.delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 브라우저 ↔ 작업 현황 사이 끌어서 너비 바꾸는 막대
|
/// 브라우저 ↔ 작업 현황 사이 끌어서 너비 바꾸는 막대
|
||||||
class _SplitHandle extends StatelessWidget {
|
class _SplitHandle extends StatelessWidget {
|
||||||
final ValueChanged<double> onDrag;
|
final ValueChanged<double> onDrag;
|
||||||
|
|
@ -871,12 +919,28 @@ class _InAppNav implements WebNav {
|
||||||
'https://tv.naver.com/',
|
'https://tv.naver.com/',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearData() async {
|
||||||
|
Future<void> quiet(Future<void> Function() f) async {
|
||||||
|
try {
|
||||||
|
await f();
|
||||||
|
} catch (_) {} // 그 엔진이 지원하지 않는 것은 건너뛴다
|
||||||
|
}
|
||||||
|
|
||||||
|
await quiet(() => CookieManager.instance(webViewEnvironment: env).deleteAllCookies());
|
||||||
|
await quiet(() => WebStorageManager.instance().deleteAllData());
|
||||||
|
await quiet(() => c.clearHistory());
|
||||||
|
await quiet(() => InAppWebViewController.clearAllCache());
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> exportCookies() async {
|
Future<void> exportCookies() async {
|
||||||
if (dataDir.isEmpty) return;
|
if (dataDir.isEmpty) return;
|
||||||
final cm = CookieManager.instance(webViewEnvironment: env);
|
final cm = CookieManager.instance(webViewEnvironment: env);
|
||||||
final all = <CookieRecord>[];
|
final all = <CookieRecord>[];
|
||||||
|
if (!CookieExport.enabled) return;
|
||||||
for (final site in _cookieSites) {
|
for (final site in _cookieSites) {
|
||||||
|
if (!isLoginCookieDomain(Uri.parse(site).host)) continue;
|
||||||
for (final k in await cm.getCookies(url: WebUri(site))) {
|
for (final k in await cm.getCookies(url: WebUri(site))) {
|
||||||
// 만료 시각: Windows 는 초, Android 등은 밀리초로 준다 → 초로 통일
|
// 만료 시각: Windows 는 초, Android 등은 밀리초로 준다 → 초로 통일
|
||||||
final raw = k.expiresDate ?? 0;
|
final raw = k.expiresDate ?? 0;
|
||||||
|
|
@ -1046,9 +1110,25 @@ class _CefNav implements WebNav {
|
||||||
|
|
||||||
/// YouTube · Google 등의 쿠키를 yt-dlp 용 cookies.txt 로 (Edge 와 같은 파일 - 지금 쓰는 엔진의 로그인이 쓰인다).
|
/// YouTube · Google 등의 쿠키를 yt-dlp 용 cookies.txt 로 (Edge 와 같은 파일 - 지금 쓰는 엔진의 로그인이 쓰인다).
|
||||||
/// Chrome 엔진은 쿠키의 이름 · 값만 알려 주므로 만료 · 보안 표시는 기본값으로 적는다.
|
/// Chrome 엔진은 쿠키의 이름 · 값만 알려 주므로 만료 · 보안 표시는 기본값으로 적는다.
|
||||||
|
@override
|
||||||
|
Future<void> clearData() async {
|
||||||
|
// Chrome 엔진은 모두 지우기가 없어 쿠키를 하나씩 지운다
|
||||||
|
try {
|
||||||
|
final raw = await cef.WebviewManager().visitAllCookies();
|
||||||
|
if (raw is Map) {
|
||||||
|
for (final e in raw.entries) {
|
||||||
|
if (e.value is! Map) continue;
|
||||||
|
for (final name in (e.value as Map).keys) {
|
||||||
|
await cef.WebviewManager().deleteCookie('${e.key}', '$name');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> exportCookies() async {
|
Future<void> exportCookies() async {
|
||||||
if (dataDir.isEmpty) return;
|
if (dataDir.isEmpty || !CookieExport.enabled) return;
|
||||||
final raw = await cef.WebviewManager().visitAllCookies();
|
final raw = await cef.WebviewManager().visitAllCookies();
|
||||||
if (raw is! Map) return;
|
if (raw is! Map) return;
|
||||||
final all = <CookieRecord>[];
|
final all = <CookieRecord>[];
|
||||||
|
|
|
||||||
|
|
@ -243,9 +243,9 @@ class _LiveSyncTile extends StatelessWidget {
|
||||||
|
|
||||||
/// 추가 ([old] 없음: 원본 · 대상을 먼저 고른다) · 고치기 ([old])
|
/// 추가 ([old] 없음: 원본 · 대상을 먼저 고른다) · 고치기 ([old])
|
||||||
static Future<void> _edit(BuildContext context, AppController c, {LiveSyncPair? old}) async {
|
static Future<void> _edit(BuildContext context, AppController c, {LiveSyncPair? old}) async {
|
||||||
var src = old?.source ?? await pickFolder(context, tr('실시간 동기화: 원본 폴더'));
|
var src = old?.source ?? await pickFolderOrDav(context, tr('실시간 동기화: 원본 폴더'));
|
||||||
if (src == null || !context.mounted) return;
|
if (src == null || !context.mounted) return;
|
||||||
var dst = old?.target ?? await pickFolder(context, tr('실시간 동기화: 대상 폴더'));
|
var dst = old?.target ?? await pickFolderOrDav(context, tr('실시간 동기화: 대상 폴더'));
|
||||||
if (dst == null || !context.mounted) return;
|
if (dst == null || !context.mounted) return;
|
||||||
var method = old?.method ?? (copyMethodAvailable(CopyMethod.rsync, c.settings) ? 'rsync' : 'builtin');
|
var method = old?.method ?? (copyMethodAvailable(CopyMethod.rsync, c.settings) ? 'rsync' : 'builtin');
|
||||||
var delete = old?.delete ?? false;
|
var delete = old?.delete ?? false;
|
||||||
|
|
@ -262,7 +262,7 @@ class _LiveSyncTile extends StatelessWidget {
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final r = await pickFolder(ctx, label);
|
final r = await pickFolderOrDav(ctx, label);
|
||||||
if (r != null) onPick(r);
|
if (r != null) onPick(r);
|
||||||
},
|
},
|
||||||
child: Text(tr('바꾸기')),
|
child: Text(tr('바꾸기')),
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import '../app/copy_center.dart';
|
||||||
import '../app/live_sync.dart';
|
import '../app/live_sync.dart';
|
||||||
import '../app/settings.dart' show CopyTask;
|
import '../app/settings.dart' show CopyTask;
|
||||||
import '../app/transfer_job.dart';
|
import '../app/transfer_job.dart';
|
||||||
|
import '../core/secret_gate.dart';
|
||||||
import '../core/sync_tools.dart';
|
import '../core/sync_tools.dart';
|
||||||
import '../core/file_ops.dart';
|
import '../core/file_ops.dart';
|
||||||
import '../core/playlist.dart' show isAudioFile, isVideoFile;
|
import '../core/playlist.dart' show isAudioFile, isVideoFile;
|
||||||
|
|
@ -563,6 +564,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
||||||
/// 그 폴더로: 저장 장치부터 그 폴더까지 펼치고 지금 폴더로 정한다
|
/// 그 폴더로: 저장 장치부터 그 폴더까지 펼치고 지금 폴더로 정한다
|
||||||
/// [fresh]: 경로 입력 · 찾기 · 내역으로 갈 때 - 그 폴더를 새로 읽는다 (앱 밖에서 바뀐 것까지 보이게, 122)
|
/// [fresh]: 경로 입력 · 찾기 · 내역으로 갈 때 - 그 폴더를 새로 읽는다 (앱 밖에서 바뀐 것까지 보이게, 122)
|
||||||
Future<void> _goTo(_Pane pane, String dir, {bool remember = true, bool fresh = false}) async {
|
Future<void> _goTo(_Pane pane, String dir, {bool remember = true, bool fresh = false}) async {
|
||||||
|
// 124 · 128: 사용자가 WebDAV 로 간 것이면 마스터 창을 한 번 취소했어도 다시 묻는다 (켤 때 되살리는 것은 [remember] 없음)
|
||||||
|
if (remember && isDav(dir)) await SecretGate.pass(force: true);
|
||||||
|
if (!mounted) return;
|
||||||
pane.root = _volumeOf(dir);
|
pane.root = _volumeOf(dir);
|
||||||
final chain = <String>[];
|
final chain = <String>[];
|
||||||
dir = isDav(dir) ? vNorm(dir) : p.normalize(dir);
|
dir = isDav(dir) ? vNorm(dir) : p.normalize(dir);
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,119 @@ import 'dart:io';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../core/vfs.dart';
|
||||||
|
import '../core/webdav.dart';
|
||||||
|
import '../l10n/tr.dart';
|
||||||
import 'android_file_browser.dart';
|
import 'android_file_browser.dart';
|
||||||
|
|
||||||
/// 폴더 고르기. Android 는 앱 안 화면 (내장 저장소 · SD 카드 · USB 를 실제 경로로 고를 수 있음), PC 는 Windows 폴더 선택 창
|
/// 폴더 고르기. Android 는 앱 안 화면 (내장 저장소 · SD 카드 · USB 를 실제 경로로 고를 수 있음), PC 는 Windows 폴더 선택 창
|
||||||
Future<String?> pickFolder(BuildContext context, String title, [String? initial]) => Platform.isAndroid
|
Future<String?> pickFolder(BuildContext context, String title, [String? initial]) => Platform.isAndroid
|
||||||
? showAndroidFolderBrowser(context, title: title, initialDirectory: initial)
|
? showAndroidFolderBrowser(context, title: title, initialDirectory: initial)
|
||||||
: FilePicker.getDirectoryPath(dialogTitle: title, initialDirectory: initial);
|
: FilePicker.getDirectoryPath(dialogTitle: title, initialDirectory: initial);
|
||||||
|
|
||||||
|
/// 124: 이 기기 폴더 또는 WebDAV 서버의 폴더 고르기 (서버가 없으면 바로 이 기기 폴더).
|
||||||
|
/// 환경 설정에서 추가한 서버가 실시간 동기화 · 모니터링의 고르는 곳에도 바로 보이게.
|
||||||
|
Future<String?> pickFolderOrDav(BuildContext context, String title, [String? initial]) async {
|
||||||
|
final servers = DavRegistry.servers;
|
||||||
|
if (servers.isEmpty) return pickFolder(context, title, initial);
|
||||||
|
final where = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => SimpleDialog(
|
||||||
|
title: Text(title),
|
||||||
|
children: [
|
||||||
|
SimpleDialogOption(
|
||||||
|
onPressed: () => Navigator.pop(ctx, ''),
|
||||||
|
child: ListTile(leading: const Icon(Icons.folder_outlined), title: Text(tr('이 기기의 폴더'))),
|
||||||
|
),
|
||||||
|
for (final s in servers)
|
||||||
|
SimpleDialogOption(
|
||||||
|
onPressed: () => Navigator.pop(ctx, s.id),
|
||||||
|
child: ListTile(leading: const Icon(Icons.cloud_outlined), title: Text(s.label), subtitle: Text(s.url)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (where == null || !context.mounted) return null;
|
||||||
|
if (where.isEmpty) return pickFolder(context, title, initial != null && !isDav(initial) ? initial : null);
|
||||||
|
final start = initial != null && isDav(initial) && DavPath.parse(initial).server == where ? initial : '$davScheme$where/';
|
||||||
|
return showDialog<String>(context: context, builder: (_) => _DavFolderDialog(title: title, start: start));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebDAV 서버 안의 폴더를 들어가며 고른다
|
||||||
|
class _DavFolderDialog extends StatefulWidget {
|
||||||
|
const _DavFolderDialog({required this.title, required this.start});
|
||||||
|
final String title;
|
||||||
|
final String start;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_DavFolderDialog> createState() => _DavFolderDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DavFolderDialogState extends State<_DavFolderDialog> {
|
||||||
|
late String _dir = widget.start;
|
||||||
|
List<String>? _folders;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_open(_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _open(String dir) async {
|
||||||
|
setState(() {
|
||||||
|
_dir = dir;
|
||||||
|
_folders = null;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final list = await vList(dir, strict: true);
|
||||||
|
final names = [for (final e in list) if (e.isDir) e.path]..sort((a, b) => vBasename(a).toLowerCase().compareTo(vBasename(b).toLowerCase()));
|
||||||
|
if (mounted && _dir == dir) setState(() => _folders = names);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted && _dir == dir) setState(() => _error = '$e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final atRoot = DavPath.parse(_dir).rel.replaceAll('/', '').isEmpty;
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(widget.title),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 460,
|
||||||
|
height: 380,
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||||
|
Text(vDisplay(_dir) + DavPath.parse(_dir).rel, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
|
const Divider(),
|
||||||
|
Expanded(
|
||||||
|
child: _error != null
|
||||||
|
? Center(child: Text(_error!, style: const TextStyle(color: Colors.redAccent)))
|
||||||
|
: _folders == null
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: ListView(children: [
|
||||||
|
if (!atRoot)
|
||||||
|
ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: const Icon(Icons.arrow_upward),
|
||||||
|
title: const Text('..'),
|
||||||
|
onTap: () => _open(vDirname(_dir)),
|
||||||
|
),
|
||||||
|
for (final f in _folders!)
|
||||||
|
ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: const Icon(Icons.folder_outlined),
|
||||||
|
title: Text(vBasename(f)),
|
||||||
|
onTap: () => _open(f),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))),
|
||||||
|
FilledButton(onPressed: _error != null ? null : () => Navigator.pop(context, _dir), child: Text(tr('이 폴더 고르기'))),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,367 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../app/app_controller.dart';
|
||||||
|
import '../app/master_lock.dart';
|
||||||
|
import '../app/settings.dart';
|
||||||
|
import '../core/secret_gate.dart';
|
||||||
|
import '../services/secret_store.dart';
|
||||||
|
import '../l10n/tr.dart';
|
||||||
|
import 'confirm.dart';
|
||||||
|
import 'theme.dart';
|
||||||
|
|
||||||
|
/// 124: 마스터 비밀번호 입력 창.
|
||||||
|
/// - 마스터를 넣으면 풀린다 (앱이 켜져 있는 동안).
|
||||||
|
/// - 최상 비밀번호를 넣으면 초기화 창 ([forChange] 면 최상도 바꾸기 · 끄기 허락으로 받는다).
|
||||||
|
/// - [비밀번호를 잊었습니다]: 저장된 비밀번호를 지우고 마스터를 초기화 (비밀번호가 새어 나가지 않게).
|
||||||
|
/// 써도 되면 true.
|
||||||
|
Future<bool> askMaster(BuildContext context, AppController c, MasterLock lock,
|
||||||
|
{String? reason, bool forChange = false}) async {
|
||||||
|
final r = await showDialog<_Result>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (_) => _MasterDialog(lock: lock, reason: reason, forChange: forChange),
|
||||||
|
);
|
||||||
|
if (!context.mounted) return false;
|
||||||
|
switch (r) {
|
||||||
|
case _Result.ok:
|
||||||
|
return true;
|
||||||
|
case _Result.superPassword:
|
||||||
|
if (forChange) return true;
|
||||||
|
await showSuperReset(context, c, lock);
|
||||||
|
return lock.unlocked || !lock.hasMaster;
|
||||||
|
case _Result.forgot:
|
||||||
|
return forgotMaster(context, c, lock);
|
||||||
|
case _Result.cancel:
|
||||||
|
case null:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _Result { ok, superPassword, forgot, cancel }
|
||||||
|
|
||||||
|
class _MasterDialog extends StatefulWidget {
|
||||||
|
const _MasterDialog({required this.lock, this.reason, required this.forChange});
|
||||||
|
final MasterLock lock;
|
||||||
|
final String? reason;
|
||||||
|
final bool forChange;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_MasterDialog> createState() => _MasterDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MasterDialogState extends State<_MasterDialog> {
|
||||||
|
final _text = TextEditingController();
|
||||||
|
bool _busy = false;
|
||||||
|
String? _error;
|
||||||
|
Timer? _tick;
|
||||||
|
|
||||||
|
MasterLock get lock => widget.lock;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tick = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
if (mounted && lock.waitUntil != null) setState(() {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tick?.cancel();
|
||||||
|
_text.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_busy || _text.text.isEmpty || lock.waiting != null) return;
|
||||||
|
setState(() => _busy = true);
|
||||||
|
final r = await lock.check(_text.text);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _busy = false);
|
||||||
|
switch (r) {
|
||||||
|
case MasterInput.master:
|
||||||
|
Navigator.pop(context, _Result.ok);
|
||||||
|
case MasterInput.superPassword:
|
||||||
|
Navigator.pop(context, _Result.superPassword);
|
||||||
|
case MasterInput.wrong:
|
||||||
|
case MasterInput.wait:
|
||||||
|
_text.clear();
|
||||||
|
setState(() => _error = trf('비밀번호가 틀렸습니다 ({0}번)', [lock.fails]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final wait = lock.waiting;
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
icon: const Icon(Icons.lock_outline),
|
||||||
|
title: Text(widget.forChange ? tr('지금 마스터 (또는 최상) 비밀번호') : tr('마스터 비밀번호')),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 420,
|
||||||
|
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text(widget.reason ?? tr('저장된 비밀번호를 쓰는 기능 · 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.')),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _text,
|
||||||
|
obscureText: true,
|
||||||
|
autofocus: true,
|
||||||
|
enabled: !_busy && wait == null,
|
||||||
|
decoration: InputDecoration(labelText: tr('비밀번호'), errorText: _error),
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
if (wait != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Text(trf('여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다', [wait.inSeconds + 1]),
|
||||||
|
style: const TextStyle(color: Colors.redAccent)),
|
||||||
|
),
|
||||||
|
if (_busy) const Padding(padding: EdgeInsets.only(top: 8), child: LinearProgressIndicator()),
|
||||||
|
if (!widget.forChange)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: _busy ? null : () => Navigator.pop(context, _Result.forgot),
|
||||||
|
child: Text(tr('비밀번호를 잊었습니다')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: _busy ? null : () => Navigator.pop(context, _Result.cancel), child: Text(tr('취소'))),
|
||||||
|
FilledButton(onPressed: _busy || wait != null ? null : _submit, child: Text(tr('확인'))),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 저장된 실제 비밀번호 · API 키를 지운다 (서버 목록 · 아이디는 남김)
|
||||||
|
Future<void> clearStoredSecrets(AppController c) => c.updateSettings((s) => s
|
||||||
|
..webdavServers = [for (final x in s.webdavServers) x.copyWith(password: '')]
|
||||||
|
..openSubtitlesKey = ''
|
||||||
|
..openSubtitlesPassword = '');
|
||||||
|
|
||||||
|
/// 앱 설정 전체를 처음 상태로 (다음에 켤 때부터 모두 적용되도록 앱을 다시 켜 달라고 한다)
|
||||||
|
Future<void> resetAllSettings(AppController c) async {
|
||||||
|
c.settings = AppSettings();
|
||||||
|
await c.updateSettings((_) {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 마스터를 잊었을 때: 저장된 비밀번호를 지우고 마스터를 초기화한다 (확인 뒤). 했으면 true
|
||||||
|
Future<bool> forgotMaster(BuildContext context, AppController c, MasterLock lock) async {
|
||||||
|
final ok = await confirmAction(
|
||||||
|
context,
|
||||||
|
title: tr('저장된 비밀번호를 지우고 마스터 초기화'),
|
||||||
|
body: tr('마스터 비밀번호를 모르면 저장된 비밀번호를 쓸 수 없습니다. 비밀번호가 새어 나가지 않도록, '
|
||||||
|
'저장된 WebDAV · OpenSubtitles 비밀번호와 API 키를 지우고 마스터 비밀번호를 초기화합니다. '
|
||||||
|
'서버 목록 · 아이디 · 다른 설정은 그대로이며, 비밀번호는 다시 넣어야 합니다.'),
|
||||||
|
ok: tr('지우고 초기화'),
|
||||||
|
);
|
||||||
|
if (!ok || !context.mounted) return false;
|
||||||
|
await clearStoredSecrets(c);
|
||||||
|
await lock.clearMaster();
|
||||||
|
if (context.mounted) {
|
||||||
|
await _done(context, c, lock, [tr('저장된 비밀번호 · API 키를 지웠습니다'), tr('마스터 비밀번호를 초기화했습니다')]);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 최상 비밀번호를 넣었을 때: 무엇을 초기화할지 고른다 (처음 체크는 마스터만) → 한 번 더 확인 → 하고 알림
|
||||||
|
Future<void> showSuperReset(BuildContext context, AppController c, MasterLock lock) async {
|
||||||
|
var master = lock.hasMaster, secrets = false, settings = false;
|
||||||
|
final go = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => StatefulBuilder(
|
||||||
|
builder: (ctx, set) => AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
icon: const Icon(Icons.restart_alt),
|
||||||
|
title: Text(tr('최상 비밀번호 - 초기화')),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 460,
|
||||||
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
Text(tr('초기화할 것을 고르세요.')),
|
||||||
|
CheckboxListTile(
|
||||||
|
value: master,
|
||||||
|
onChanged: lock.hasMaster ? (v) => set(() => master = v!) : null,
|
||||||
|
title: Text(tr('마스터 비밀번호')),
|
||||||
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
value: secrets,
|
||||||
|
onChanged: (v) => set(() => secrets = v!),
|
||||||
|
title: Text(tr('저장된 실제 비밀번호 · API 키')),
|
||||||
|
subtitle: Text(tr('WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다')),
|
||||||
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
value: settings,
|
||||||
|
onChanged: (v) => set(() => settings = v!),
|
||||||
|
title: Text(tr('앱 설정 전체 (처음 상태로)')),
|
||||||
|
subtitle: Text(tr('WebDAV 서버 목록과 그 저장된 비밀번호도 지워집니다. 동영상 목록 · 즐겨찾기 · 파일은 그대로입니다')),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: master || secrets || settings ? () => Navigator.pop(ctx, true) : null,
|
||||||
|
child: Text(tr('초기화')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (go != true || !context.mounted) return;
|
||||||
|
final what = [
|
||||||
|
if (master) tr('마스터 비밀번호'),
|
||||||
|
if (secrets) tr('저장된 실제 비밀번호 · API 키'),
|
||||||
|
if (settings) tr('앱 설정 전체 (처음 상태로)'),
|
||||||
|
];
|
||||||
|
final sure = await confirmAction(
|
||||||
|
context,
|
||||||
|
title: tr('정말 초기화할까요?'),
|
||||||
|
body: '${what.map((x) => '• $x').join('\n')}\n\n${tr('되돌릴 수 없습니다.')}',
|
||||||
|
ok: tr('초기화'),
|
||||||
|
);
|
||||||
|
if (!sure || !context.mounted) return;
|
||||||
|
final done = <String>[];
|
||||||
|
if (secrets) {
|
||||||
|
await clearStoredSecrets(c);
|
||||||
|
done.add(tr('저장된 비밀번호 · API 키를 지웠습니다'));
|
||||||
|
}
|
||||||
|
if (settings) {
|
||||||
|
await resetAllSettings(c);
|
||||||
|
done.add(tr('앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)'));
|
||||||
|
}
|
||||||
|
if (master) {
|
||||||
|
await lock.clearMaster();
|
||||||
|
done.add(tr('마스터 비밀번호를 초기화했습니다'));
|
||||||
|
} else {
|
||||||
|
lock.unlocked = true; // 최상을 아는 사람
|
||||||
|
}
|
||||||
|
if (context.mounted) await _done(context, c, lock, done);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 무엇을 했는지 알리고, 마스터가 없어졌으면 새로 정할 수 있게
|
||||||
|
Future<void> _done(BuildContext context, AppController c, MasterLock lock, List<String> done) async {
|
||||||
|
final setNew = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
icon: const Icon(Icons.check_circle_outline, color: JjColors.accent),
|
||||||
|
title: Text(tr('초기화했습니다')),
|
||||||
|
content: Text(done.map((x) => '• $x').join('\n')),
|
||||||
|
actions: [
|
||||||
|
if (!lock.hasMaster)
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('새 마스터 비밀번호 정하기'))),
|
||||||
|
FilledButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('닫기'))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (setNew == true && context.mounted) await setPasswordDialog(context, c, lock, superPassword: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 새 마스터 · 최상 비밀번호를 정한다 (두 번 넣기). 정했으면 true
|
||||||
|
Future<bool> setPasswordDialog(BuildContext context, AppController c, MasterLock lock, {required bool superPassword}) async {
|
||||||
|
final a = TextEditingController(), b = TextEditingController();
|
||||||
|
String? error;
|
||||||
|
var busy = false;
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (ctx) => StatefulBuilder(
|
||||||
|
builder: (ctx, set) {
|
||||||
|
Future<void> save() async {
|
||||||
|
if (a.text.isEmpty) return set(() => error = tr('비밀번호를 넣으세요'));
|
||||||
|
if (a.text != b.text) return set(() => error = tr('두 번 넣은 비밀번호가 다릅니다'));
|
||||||
|
set(() => busy = true);
|
||||||
|
final fine = superPassword ? await lock.setSuper(a.text) : await lock.setMaster(a.text);
|
||||||
|
if (!ctx.mounted) return;
|
||||||
|
if (!fine) {
|
||||||
|
return set(() {
|
||||||
|
busy = false;
|
||||||
|
error = superPassword
|
||||||
|
? tr('마스터 비밀번호와 같게 정할 수 없습니다')
|
||||||
|
: tr('최상 비밀번호와 같게 정할 수 없습니다');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Navigator.pop(ctx, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: Text(superPassword ? tr('최상 비밀번호 정하기') : tr('마스터 비밀번호 정하기')),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 420,
|
||||||
|
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text(superPassword
|
||||||
|
? tr('최상 비밀번호는 모든 것을 초기화하는 열쇠입니다. 마스터 입력 창에 넣으면 초기화 창이 열립니다.')
|
||||||
|
: tr('저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다. 잊으면 최상 비밀번호로 초기화하거나, '
|
||||||
|
'저장된 비밀번호를 지우고 초기화할 수 있습니다.')),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(tr('이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.'),
|
||||||
|
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||||
|
TextField(
|
||||||
|
controller: a,
|
||||||
|
obscureText: true,
|
||||||
|
autofocus: true,
|
||||||
|
enabled: !busy,
|
||||||
|
decoration: InputDecoration(labelText: tr('새 비밀번호'))),
|
||||||
|
TextField(
|
||||||
|
controller: b,
|
||||||
|
obscureText: true,
|
||||||
|
enabled: !busy,
|
||||||
|
decoration: InputDecoration(labelText: tr('한 번 더'), errorText: error),
|
||||||
|
onSubmitted: (_) => save(),
|
||||||
|
),
|
||||||
|
if (busy) const Padding(padding: EdgeInsets.only(top: 8), child: LinearProgressIndicator()),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: busy ? null : () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||||||
|
FilledButton(onPressed: busy ? null : save, child: Text(tr('저장'))),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
a.dispose();
|
||||||
|
b.dispose();
|
||||||
|
// 처음 정하면 묻는 때는 "비밀번호가 저장된 기능을 쓸 때" (안전 저장소에 값이 없으면 그렇게 본다 - [MasterLock.ask])
|
||||||
|
return ok == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 묻는 창을 띄울 화면 ([attachMasterPrompt])
|
||||||
|
GlobalKey<NavigatorState>? _masterNav;
|
||||||
|
|
||||||
|
/// 앱을 켤 때 (설정을 읽은 바로 뒤, 실시간 동기화 등이 서버에 붙기 전): 마스터 잠금을 만들고
|
||||||
|
/// (안전 저장소의 해시 읽기) 저장된 비밀번호를 쓰는 곳들이 지나는 문에 건다.
|
||||||
|
/// [secrets]: 설정 저장소가 없는 창 (재생만 하는 두 번째 창) 이 쓰는 안전 저장소
|
||||||
|
Future<MasterLock> setupMasterLock(AppController c, {SecretStore? secrets}) async {
|
||||||
|
final lock = MasterLock(secrets ?? c.settingsStore?.secrets ?? SecretStore.platform());
|
||||||
|
try {
|
||||||
|
await lock.load();
|
||||||
|
} catch (_) {}
|
||||||
|
MasterLock.instance = lock;
|
||||||
|
MasterLock.prompt = (reason) async {
|
||||||
|
// 화면이 아직 없으면 (켜는 중) 뜰 때까지 조금 기다린다
|
||||||
|
for (var i = 0; i < 60; i++) {
|
||||||
|
final ctx = _masterNav?.currentContext;
|
||||||
|
if (ctx != null && ctx.mounted) return askMaster(ctx, c, lock, reason: reason);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
SecretGate.check = (force) => lock.ensure(force: force);
|
||||||
|
return lock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 마스터를 묻는 창을 띄울 화면
|
||||||
|
void attachMasterPrompt(GlobalKey<NavigatorState> navigatorKey) => _masterNav = navigatorKey;
|
||||||
|
|
||||||
|
/// "처음 시작 시" 로 정해 두었으면 켤 때 한 번 묻는다. 배경 작업 (실시간 동기화 등) 과 같은 창 하나로 (128).
|
||||||
|
/// 취소해도 앱은 쓸 수 있고, 이번 실행 동안 배경 작업은 묻지 않고 건너뛴다 (사용자가 직접 누르면 다시 묻는다).
|
||||||
|
Future<void> askMasterAtStartup(BuildContext context, AppController c) async {
|
||||||
|
final lock = MasterLock.instance;
|
||||||
|
if (lock == null || !lock.needsPrompt(startup: true) || lock.ask != 'startup') return;
|
||||||
|
await lock.ensure(force: true);
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,8 @@ Future<void> showMigrationNotice(BuildContext context, AppController c) async {
|
||||||
'explorerLayout' =>
|
'explorerLayout' =>
|
||||||
tr('파일 탐색기 창: 두 창 → 화면 크기 따라 (폰 세로는 한 창). 되돌리기: 환경 설정 > 파일 탐색기 > 창 배치'),
|
tr('파일 탐색기 창: 두 창 → 화면 크기 따라 (폰 세로는 한 창). 되돌리기: 환경 설정 > 파일 탐색기 > 창 배치'),
|
||||||
'explorerClick' => tr('파일 탐색기 누르기: 한 번 누르면 선택 → 한 번 누르면 바로 실행. 되돌리기: 환경 설정 > 파일 탐색기 > 누르기'),
|
'explorerClick' => tr('파일 탐색기 누르기: 한 번 누르면 선택 → 한 번 누르면 바로 실행. 되돌리기: 환경 설정 > 파일 탐색기 > 누르기'),
|
||||||
|
'cookieScope' => tr('앱 안 브라우저의 로그인 쿠키: YouTube · Google 에 더해 Instagram · X · 네이버 (치지직) 로그인도 '
|
||||||
|
'다운로드 (yt-dlp) 에 씁니다. 고르기 · 끄기 · 지우기: 환경 설정 > 다운로드 > YouTube 쿠키'),
|
||||||
'explorerOrientation' => tr(
|
'explorerOrientation' => tr(
|
||||||
'파일 탐색기 두 창 배치: 화면 모양 따라 → 좌우. 되돌리기: 파일 탐색기의 [좌우 ⇆ / 위아래 ⇅] 버튼 또는 창 배치'),
|
'파일 탐색기 두 창 배치: 화면 모양 따라 → 좌우. 되돌리기: 파일 탐색기의 [좌우 ⇆ / 위아래 ⇅] 버튼 또는 창 배치'),
|
||||||
'zipComic' => tr('ZIP · CBZ 두 번 누르기: 만화 보기 → 목록. 되돌리기: 환경 설정 > 파일 탐색기 > ZIP · CBZ 를 만화로 보기'),
|
'zipComic' => tr('ZIP · CBZ 두 번 누르기: 만화 보기 → 목록. 되돌리기: 환경 설정 > 파일 탐색기 > ZIP · CBZ 를 만화로 보기'),
|
||||||
|
|
@ -47,6 +49,8 @@ Future<void> showMigrationNotice(BuildContext context, AppController c) async {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
c.settings.migrated = [];
|
c.settings.migrated = [];
|
||||||
|
// 126: 옮긴 값을 바로 파일에 남긴다 (안 그러면 강제 종료 뒤 켤 때 예전 값을 다시 읽어 이 창이 또 뜬다)
|
||||||
|
await c.updateSettings((_) {});
|
||||||
if (open == true && context.mounted) {
|
if (open == true && context.mounted) {
|
||||||
await Navigator.push(context, MaterialPageRoute<void>(builder: (_) => SettingsPage(c: c)));
|
await Navigator.push(context, MaterialPageRoute<void>(builder: (_) => SettingsPage(c: c)));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import '../app/copy_center.dart';
|
||||||
import '../app/live_sync.dart';
|
import '../app/live_sync.dart';
|
||||||
import '../app/settings.dart';
|
import '../app/settings.dart';
|
||||||
import '../app/transfer_job.dart';
|
import '../app/transfer_job.dart';
|
||||||
|
import '../core/secret_gate.dart';
|
||||||
import '../core/file_ops.dart';
|
import '../core/file_ops.dart';
|
||||||
import '../core/sync_tools.dart';
|
import '../core/sync_tools.dart';
|
||||||
import '../core/vfs.dart';
|
import '../core/vfs.dart';
|
||||||
|
|
@ -750,6 +751,41 @@ class _LiveTabState extends State<_LiveTab> {
|
||||||
final k = LiveSync.keyOf(x);
|
final k = LiveSync.keyOf(x);
|
||||||
final dels = live.emptyDeletes[k] ?? const <String>[];
|
final dels = live.emptyDeletes[k] ?? const <String>[];
|
||||||
final running = live.isRunning(x);
|
final running = live.isRunning(x);
|
||||||
|
// 128: 마스터 비밀번호를 기다리는 중 - 고장이 아니라 넣으면 이어서 맞춘다
|
||||||
|
if (e.locked) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: JjColors.accent.withValues(alpha: 0.10),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: JjColors.accent.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Row(children: [
|
||||||
|
const Icon(Icons.lock_clock_outlined, color: JjColors.accent, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(tr('마스터 비밀번호가 필요해 기다리는 중'),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600, color: JjColors.accent)),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(tr('이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호가 필요합니다. 넣으면 이어서 맞춥니다. 대상 파일은 건드리지 않았습니다.'),
|
||||||
|
style: const TextStyle(fontSize: 12)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
onPressed: running
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
if (await SecretGate.pass(force: true)) await live.syncNow(x);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.lock_open, size: 18),
|
||||||
|
label: Text(tr('마스터 비밀번호 넣기')),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
|
padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
|
||||||
|
|
|
||||||
|
|
@ -644,7 +644,10 @@ class _ReaderPageState extends State<ReaderPage> {
|
||||||
color: _barColor,
|
color: _barColor,
|
||||||
padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom, left: 12, right: 12),
|
padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom, left: 12, right: 12),
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
Text('${n == 0 ? 0 : _label(_cur)} / $n', style: const TextStyle(color: Colors.white, fontFamily: 'Consolas')),
|
// 125: 반으로 나눈 쪽이 있으면 전체 수가 파일 수 (장) 임을 단위로 ("2-1 / 3장")
|
||||||
|
Text(
|
||||||
|
count != n ? trf('{0} / {1}장', [_label(_cur), n]) : '${n == 0 ? 0 : _label(_cur)} / $n',
|
||||||
|
style: const TextStyle(color: Colors.white, fontFamily: 'Consolas')),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: count < 2
|
child: count < 2
|
||||||
? const SizedBox(height: 48)
|
? const SizedBox(height: 48)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../app/app_controller.dart';
|
||||||
|
import '../app/master_lock.dart';
|
||||||
|
import '../l10n/tr.dart';
|
||||||
|
import 'confirm.dart';
|
||||||
|
import 'master_prompt.dart';
|
||||||
|
import 'setting_tile.dart';
|
||||||
|
|
||||||
|
/// 124: 환경 설정 > 보안 - 최상 비밀번호 (모두 초기화하는 열쇠) · 마스터 비밀번호 (저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능)
|
||||||
|
/// · 마스터를 묻는 때. 이미 정한 것을 바꾸거나 끄려면 지금 마스터 (또는 최상) 를 넣는다.
|
||||||
|
class SecuritySettings extends StatelessWidget {
|
||||||
|
final AppController c;
|
||||||
|
const SecuritySettings({super.key, required this.c});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final lock = MasterLock.instance;
|
||||||
|
if (lock == null) return const SizedBox();
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: lock,
|
||||||
|
builder: (context, _) {
|
||||||
|
// 바꾸기 · 끄기 전에 지금 비밀번호 (마스터 또는 최상)
|
||||||
|
Future<bool> current() => askMaster(context, c, lock,
|
||||||
|
forChange: true, reason: tr('바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.'));
|
||||||
|
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||||
|
SettingTile(
|
||||||
|
leading: const Icon(Icons.lock_outline),
|
||||||
|
title: Text(tr('마스터 비밀번호')),
|
||||||
|
// 132: 버튼은 설명 아래에 (넓은 화면의 오른쪽 칸에서는 둘째 버튼이 잘려 안 보였다)
|
||||||
|
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text(lock.hasMaster
|
||||||
|
? tr('정해 둠. 저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다 (앱을 끌 때까지 한 번)')
|
||||||
|
: tr('정하지 않음. 정하면 저장된 비밀번호 (WebDAV · OpenSubtitles · API 키) 와 설정을 쓰기 전에 묻습니다')),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Wrap(spacing: 6, runSpacing: 6, children: [
|
||||||
|
if (!lock.hasMaster)
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: () => setPasswordDialog(context, c, lock, superPassword: false),
|
||||||
|
child: Text(tr('정하기')),
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
if (await current() && context.mounted) await setPasswordDialog(context, c, lock, superPassword: false);
|
||||||
|
},
|
||||||
|
child: Text(tr('바꾸기')),
|
||||||
|
),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
if (!await current() || !context.mounted) return;
|
||||||
|
final ok = await confirmAction(context,
|
||||||
|
title: tr('마스터 비밀번호를 끌까요?'),
|
||||||
|
body: tr('저장된 비밀번호 · 설정을 쓸 때 더는 묻지 않습니다.'),
|
||||||
|
ok: tr('끄기'));
|
||||||
|
if (!ok) return;
|
||||||
|
await lock.clearMaster();
|
||||||
|
},
|
||||||
|
child: Text(tr('끄기')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
SettingTile(
|
||||||
|
enabled: lock.hasMaster,
|
||||||
|
leading: const Icon(Icons.schedule),
|
||||||
|
title: Text(tr('마스터 비밀번호를 묻는 때')),
|
||||||
|
trailing: DropdownButton<String>(
|
||||||
|
value: lock.ask,
|
||||||
|
items: [
|
||||||
|
DropdownMenuItem(value: 'never', child: Text(tr('물어보지 않기'))),
|
||||||
|
DropdownMenuItem(value: 'startup', child: Text(tr('처음 시작 시'))),
|
||||||
|
DropdownMenuItem(value: 'onUse', child: Text(tr('비밀번호가 저장된 기능을 쓸 때'))),
|
||||||
|
],
|
||||||
|
// 129: 바꾸는 것도 지금 마스터를 넣어야 (자물쇠를 아무나 끄지 못하게)
|
||||||
|
onChanged: !lock.hasMaster
|
||||||
|
? null
|
||||||
|
: (v) async {
|
||||||
|
if (v == lock.ask) return;
|
||||||
|
if (await current()) await lock.setAsk(v!);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SettingTile(
|
||||||
|
leading: const Icon(Icons.key_outlined),
|
||||||
|
title: Text(tr('최상 비밀번호')),
|
||||||
|
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text(lock.hasSuper
|
||||||
|
? tr('정해 둠. 마스터 입력 창에 넣으면 초기화 창이 열립니다 (마스터 · 저장된 비밀번호 · 앱 설정 전체 중 고름)')
|
||||||
|
: tr('정하지 않음. 마스터를 잊으면 저장된 비밀번호를 지우고 마스터를 초기화하는 것만 할 수 있습니다')),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Wrap(spacing: 6, runSpacing: 6, children: [
|
||||||
|
if (!lock.hasSuper)
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: () async {
|
||||||
|
// 마스터가 있으면 그것을 먼저 (아무나 최상을 정하지 못하게)
|
||||||
|
if (lock.hasMaster && !await current()) return;
|
||||||
|
if (context.mounted) await setPasswordDialog(context, c, lock, superPassword: true);
|
||||||
|
},
|
||||||
|
child: Text(tr('정하기')),
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
if (await current() && context.mounted) await setPasswordDialog(context, c, lock, superPassword: true);
|
||||||
|
},
|
||||||
|
child: Text(tr('바꾸기')),
|
||||||
|
),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
if (!await current() || !context.mounted) return;
|
||||||
|
final ok = await confirmAction(context, title: tr('최상 비밀번호를 끌까요?'), body: '', ok: tr('끄기'));
|
||||||
|
if (ok) await lock.clearSuper();
|
||||||
|
},
|
||||||
|
child: Text(tr('끄기')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,9 @@ import 'theme.dart';
|
||||||
import '../l10n/tr.dart';
|
import '../l10n/tr.dart';
|
||||||
import 'setting_tile.dart';
|
import 'setting_tile.dart';
|
||||||
import 'component_settings.dart';
|
import 'component_settings.dart';
|
||||||
|
import 'browser_page.dart' show deleteExportedCookies;
|
||||||
|
import 'security_settings.dart';
|
||||||
|
import '../app/master_lock.dart';
|
||||||
import 'toast_status.dart';
|
import 'toast_status.dart';
|
||||||
|
|
||||||
/// 동시 작업 수 고르기: 1 · 5 · 10 · 무한(0) · 직접 입력
|
/// 동시 작업 수 고르기: 1 · 5 · 10 · 무한(0) · 직접 입력
|
||||||
|
|
@ -231,6 +234,23 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
late final _hotkey = TextEditingController(text: c.settings.showHotkey);
|
late final _hotkey = TextEditingController(text: c.settings.showHotkey);
|
||||||
String? _hotkeyError;
|
String? _hotkeyError;
|
||||||
|
|
||||||
|
/// 124: 마스터 비밀번호를 넣기 전에는 설정을 보이지 않는다 (정해 두었고 묻는 때가 "물어보지 않기" 가 아니면)
|
||||||
|
bool _locked = MasterLock.instance?.needsPrompt() ?? false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (_locked) WidgetsBinding.instance.addPostFrameCallback((_) => _unlock());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _unlock() async {
|
||||||
|
final lock = MasterLock.instance;
|
||||||
|
if (lock == null || !mounted) return;
|
||||||
|
// 128: 시작 창 · 배경 작업과 같은 창 하나로 (사용자가 연 것이니 취소한 적이 있어도 묻는다)
|
||||||
|
final ok = await lock.ensure(force: true, reason: tr('환경 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.'));
|
||||||
|
if (mounted) setState(() => _locked = !ok && lock.needsPrompt());
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_hotkey.dispose();
|
_hotkey.dispose();
|
||||||
|
|
@ -253,6 +273,24 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (_locked) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Column(children: [
|
||||||
|
AppTopBar(nav: const AppNavButtons(), actions: AppActions(c: c, onSettingsPage: true), middle: Text(tr('환경 설정'))),
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
const Icon(Icons.lock_outline, size: 48, color: JjColors.textDim),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(tr('마스터 비밀번호로 잠겨 있습니다')),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton(onPressed: _unlock, child: Text(tr('마스터 비밀번호 넣기'))),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: c,
|
listenable: c,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
|
|
@ -343,6 +381,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
// 124: 최상 · 마스터 비밀번호
|
||||||
|
_group('security', Icons.lock_outline, tr('보안'), [SecuritySettings(c: c)]),
|
||||||
_group('components', Icons.extension_outlined, tr('컴포넌트'), [ComponentSettings(c: c)]),
|
_group('components', Icons.extension_outlined, tr('컴포넌트'), [ComponentSettings(c: c)]),
|
||||||
_group('display', Icons.aspect_ratio, tr('화면'), [
|
_group('display', Icons.aspect_ratio, tr('화면'), [
|
||||||
if (!desk)
|
if (!desk)
|
||||||
|
|
@ -774,10 +814,51 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
..ytCookiesBrowser = v ?? ''
|
..ytCookiesBrowser = v ?? ''
|
||||||
..ytCookiesFile = '');
|
..ytCookiesFile = '');
|
||||||
}
|
}
|
||||||
|
// 131: 앱 안 브라우저 로그인을 더 쓰지 않으면 내보내 둔 평문 쿠키 파일을 지운다
|
||||||
|
if (c.settings.ytCookiesBrowser != internalBrowserCookies) {
|
||||||
|
await deleteExportedCookies(c.settings.webViewDataDir);
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
// 53: 앱 안 브라우저 쿠키 - 어디에 어떻게 저장되는지 · 넘길 사이트 고르기 · 지우기
|
||||||
|
// 131: 쓰지 않게 바꿨어도 파일이 남아 있으면 지우는 버튼은 보인다
|
||||||
|
if (s.ytCookiesBrowser == internalBrowserCookies || s.internalCookieFile.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text(
|
||||||
|
tr('로그인 쿠키는 다운로드 (yt-dlp) 가 쓰도록 이 기기의 앱 폴더에 cookies_youtube.txt 로 저장됩니다 (평문). '
|
||||||
|
'이 파일을 가진 사람은 그 계정에 로그인할 수 있으니 기기를 남에게 줄 때는 지우세요. 고른 사이트의 쿠키만 넘깁니다:'),
|
||||||
|
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Wrap(spacing: 6, runSpacing: 6, children: [
|
||||||
|
for (final d in loginCookieDomains)
|
||||||
|
FilterChip(
|
||||||
|
label: Text(d),
|
||||||
|
selected: s.loginCookieSites.contains(d),
|
||||||
|
onSelected: (on) => c.updateSettings((x) => x.loginCookieSites = [
|
||||||
|
for (final y in x.loginCookieSites) if (y != d) y,
|
||||||
|
if (on) d,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.cookie_outlined, size: 18),
|
||||||
|
label: Text(tr('쿠키 파일 지우기')),
|
||||||
|
onPressed: () async {
|
||||||
|
await deleteExportedCookies(s.webViewDataDir);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
setState(() {});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
|
content: Text(tr('내보낸 쿠키 파일을 지웠습니다. 브라우저의 로그인까지 지우려면 '
|
||||||
|
'웹 브라우저 화면의 [쿠키 · 방문 기록 지우기] 를 누르세요.'))));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
),
|
||||||
SettingTile(
|
SettingTile(
|
||||||
title: Text(tr('동시 다운로드 수')),
|
title: Text(tr('동시 다운로드 수')),
|
||||||
subtitle: Text(tr('나머지는 대기했다가 차례로 받습니다')),
|
subtitle: Text(tr('나머지는 대기했다가 차례로 받습니다')),
|
||||||
|
|
@ -973,11 +1054,13 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
final _groupKeys = <String, GlobalKey>{};
|
final _groupKeys = <String, GlobalKey>{};
|
||||||
|
|
||||||
static const _groupIds = [
|
static const _groupIds = [
|
||||||
'general', 'components', 'display', 'mkv', 'subtitle', 'play', 'browser', 'files', 'rsync', 'download', 'run', 'cleanup', 'about', //
|
'general', 'security', 'components', 'display', 'mkv', 'subtitle', 'play', 'browser', 'files', 'rsync', 'download', 'run', //
|
||||||
|
'cleanup', 'about',
|
||||||
];
|
];
|
||||||
|
|
||||||
String _groupTitle(String id) => switch (id) {
|
String _groupTitle(String id) => switch (id) {
|
||||||
'general' => tr('일반'),
|
'general' => tr('일반'),
|
||||||
|
'security' => tr('보안'),
|
||||||
'display' => tr('화면'),
|
'display' => tr('화면'),
|
||||||
'components' => tr('컴포넌트'),
|
'components' => tr('컴포넌트'),
|
||||||
'mkv' => tr('MKV 만들기'),
|
'mkv' => tr('MKV 만들기'),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../core/secret_gate.dart';
|
||||||
import '../app/app_controller.dart';
|
import '../app/app_controller.dart';
|
||||||
import '../core/languages.dart';
|
import '../core/languages.dart';
|
||||||
import '../core/models.dart';
|
import '../core/models.dart';
|
||||||
|
|
@ -68,6 +69,9 @@ class _SearchDialogState extends State<_SearchDialog> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _search() async {
|
Future<void> _search() async {
|
||||||
|
// 124 · 128: 사용자가 직접 찾는 것이니 마스터 창을 한 번 취소했어도 다시 묻는다
|
||||||
|
await SecretGate.pass(force: true);
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_busy = true;
|
_busy = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ class SyncAlert {
|
||||||
final more = fresh.length > 1 ? trf(' 외 {0}개', [fresh.length - 1]) : '';
|
final more = fresh.length > 1 ? trf(' 외 {0}개', [fresh.length - 1]) : '';
|
||||||
await shell.notify(
|
await shell.notify(
|
||||||
tr('실시간 동기화가 멈췄습니다'),
|
tr('실시간 동기화가 멈췄습니다'),
|
||||||
'${e.empty ? tr('원본 폴더가 비어 있어 멈췄습니다') : tr('원본을 읽을 수 없어 멈췄습니다')}$more\n'
|
'${e.locked ? tr('마스터 비밀번호가 필요해 기다리는 중') : e.empty ? tr('원본 폴더가 비어 있어 멈췄습니다') : tr('원본을 읽을 수 없어 멈췄습니다')}$more\n'
|
||||||
'${vDisplay(e.path)} · ${tr('대상 파일은 지우지 않았습니다.')}',
|
'${vDisplay(e.path)} · ${tr('대상 파일은 지우지 않았습니다.')}',
|
||||||
onClick: openLsync,
|
onClick: openLsync,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../app/app_controller.dart';
|
import '../app/app_controller.dart';
|
||||||
|
import '../app/settings.dart';
|
||||||
|
import '../core/vfs.dart';
|
||||||
|
import '../core/secret_gate.dart';
|
||||||
import '../core/webdav.dart';
|
import '../core/webdav.dart';
|
||||||
import '../l10n/tr.dart';
|
import '../l10n/tr.dart';
|
||||||
import 'confirm.dart';
|
import 'confirm.dart';
|
||||||
|
|
@ -35,7 +38,9 @@ class WebDavSettings extends StatelessWidget {
|
||||||
dense: true,
|
dense: true,
|
||||||
leading: const Icon(Icons.cloud, color: JjColors.accent),
|
leading: const Icon(Icons.cloud, color: JjColors.accent),
|
||||||
title: Text(s.label),
|
title: Text(s.label),
|
||||||
subtitle: Text('${s.url}${s.user.isEmpty ? '' : ' · ${s.user}'}${s.insecure ? ' · ${tr('인증서 확인 안 함')}' : ''}',
|
subtitle: Text(
|
||||||
|
'${s.url}${s.user.isEmpty ? '' : ' · ${s.user}'}${s.insecure ? ' · ${tr('인증서 확인 안 함')}' : ''}'
|
||||||
|
'${isPlainHttp(s.url) ? ' · ⚠ ${tr('암호화 안 됨 (http)')}' : ''}',
|
||||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
|
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|
@ -48,11 +53,17 @@ class WebDavSettings extends StatelessWidget {
|
||||||
icon: const Icon(Icons.delete_outline, size: 18, color: JjColors.textDim),
|
icon: const Icon(Icons.delete_outline, size: 18, color: JjColors.textDim),
|
||||||
// 46: 확인 뒤 (저장된 비밀번호도 함께 지워진다)
|
// 46: 확인 뒤 (저장된 비밀번호도 함께 지워진다)
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
|
// 124: 이 서버를 쓰는 실시간 동기화 쌍 · 기억된 작업을 먼저 알린다
|
||||||
|
final users = davServerUsers(c.settings, s.id);
|
||||||
final ok = await confirmAction(
|
final ok = await confirmAction(
|
||||||
context,
|
context,
|
||||||
title: trf('WebDAV 서버 "{0}" 을(를) 지울까요?', [s.label]),
|
title: trf('WebDAV 서버 "{0}" 을(를) 지울까요?', [s.label]),
|
||||||
body: tr('서버 설정과 이 기기에 저장된 비밀번호를 지웁니다. 서버의 파일은 그대로입니다. '
|
body: tr('서버 설정과 이 기기에 저장된 비밀번호를 지웁니다. 서버의 파일은 그대로입니다. '
|
||||||
'이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.'),
|
'이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.') +
|
||||||
|
(users.isEmpty
|
||||||
|
? ''
|
||||||
|
: '\n\n${trf('이 서버를 쓰는 것 {0}개 (지워도 목록에는 남고, 실행하면 서버가 없다고 알립니다):', [users.length])}\n'
|
||||||
|
'${users.take(8).map((u) => '• $u').join('\n')}${users.length > 8 ? '\n…' : ''}'),
|
||||||
ok: tr('지우기'),
|
ok: tr('지우기'),
|
||||||
);
|
);
|
||||||
if (ok) await c.updateSettings((x) => x.webdavServers = [for (final y in x.webdavServers) if (y.id != s.id) y]);
|
if (ok) await c.updateSettings((x) => x.webdavServers = [for (final y in x.webdavServers) if (y.id != s.id) y]);
|
||||||
|
|
@ -65,8 +76,25 @@ class WebDavSettings extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 56: 암호화하지 않는 http:// 주소인지
|
||||||
|
bool isPlainHttp(String url) => url.trim().toLowerCase().startsWith('http://');
|
||||||
|
|
||||||
|
/// 124: 서버 [id] 를 쓰는 실시간 동기화 쌍 · 기억된 작업 (모니터링) 의 설명
|
||||||
|
List<String> davServerUsers(AppSettings s, String id) {
|
||||||
|
bool on(String path) => isDav(path) && DavPath.parse(path).server == id;
|
||||||
|
return [
|
||||||
|
for (final x in s.liveSyncPairs)
|
||||||
|
if (on(x.source) || on(x.target)) trf('실시간 동기화: {0} → {1}', [vDisplay(x.source), vDisplay(x.target)]),
|
||||||
|
for (final t in s.copyTasks)
|
||||||
|
if (on(t.dest) || t.sources.any(on)) trf('기억된 작업: {0} → {1}', [t.sources.map(vDisplay).join(', '), vDisplay(t.dest)]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/// WebDAV 서버 추가 · 고치기 창. 저장한 서버를 돌려준다 (취소면 null).
|
/// WebDAV 서버 추가 · 고치기 창. 저장한 서버를 돌려준다 (취소면 null).
|
||||||
Future<DavServer?> editDavServer(BuildContext context, AppController c, {DavServer? old}) async {
|
Future<DavServer?> editDavServer(BuildContext context, AppController c, {DavServer? old}) async {
|
||||||
|
// 127: 저장된 비밀번호가 칸에 채워지므로 (눈 버튼으로 보임) 먼저 마스터 비밀번호 (정해 두었으면)
|
||||||
|
if (old != null && old.password.isNotEmpty && !await SecretGate.pass(force: true)) return null;
|
||||||
|
if (!context.mounted) return null;
|
||||||
final name = TextEditingController(text: old?.name ?? '');
|
final name = TextEditingController(text: old?.name ?? '');
|
||||||
final url = TextEditingController(text: old?.url ?? 'https://');
|
final url = TextEditingController(text: old?.url ?? 'https://');
|
||||||
final user = TextEditingController(text: old?.user ?? '');
|
final user = TextEditingController(text: old?.user ?? '');
|
||||||
|
|
@ -133,6 +161,21 @@ Future<DavServer?> editDavServer(BuildContext context, AppController c, {DavServ
|
||||||
),
|
),
|
||||||
onChanged: (_) => set(() {}),
|
onChanged: (_) => set(() {}),
|
||||||
),
|
),
|
||||||
|
// 56: http:// 는 아이디 · 비밀번호와 파일이 암호화되지 않고 그대로 간다
|
||||||
|
if (isPlainHttp(url.text))
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
const Icon(Icons.warning_amber_rounded, size: 18, color: Colors.orangeAccent),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
tr('http:// 주소는 아이디 · 비밀번호와 파일이 암호화되지 않고 그대로 갑니다. 같은 네트워크의 다른 사람이 볼 수 있으니 '
|
||||||
|
'서버가 https 를 지원하면 https:// 로 바꾸세요.'),
|
||||||
|
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디'))),
|
TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디'))),
|
||||||
TextField(
|
TextField(
|
||||||
controller: pass,
|
controller: pass,
|
||||||
|
|
|
||||||
|
|
@ -715,7 +715,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.2"
|
version: "2.2.2"
|
||||||
path_provider_platform_interface:
|
path_provider_platform_interface:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path_provider_platform_interface
|
name: path_provider_platform_interface
|
||||||
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,8 @@ dependencies:
|
||||||
image: ^4.10.1
|
image: ^4.10.1
|
||||||
# 보기 화면 "계속 보기" 동안 화면이 꺼지지 않게 (media_kit 이 이미 쓰는 것)
|
# 보기 화면 "계속 보기" 동안 화면이 꺼지지 않게 (media_kit 이 이미 쓰는 것)
|
||||||
wakelock_plus: ^1.8.0
|
wakelock_plus: ^1.8.0
|
||||||
|
# 시험판이 사용자 데이터와 다른 폴더를 쓰게 (JJ_MKVMAKER_DATA, Windows) - path_provider 가 이미 쓰는 것
|
||||||
|
path_provider_platform_interface: ^2.1.2
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
flutter_inappwebview_android:
|
flutter_inappwebview_android:
|
||||||
path: packages/flutter_inappwebview_android
|
path: packages/flutter_inappwebview_android
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,8 @@ class _Nav implements WebNav {
|
||||||
@override
|
@override
|
||||||
Future<void> exportCookies() async => exported++;
|
Future<void> exportCookies() async => exported++;
|
||||||
@override
|
@override
|
||||||
|
Future<void> clearData() async {}
|
||||||
|
@override
|
||||||
Future<Object?> evaluate(String js) async {
|
Future<Object?> evaluate(String js) async {
|
||||||
scripts.add(js);
|
scripts.add(js);
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import 'package:jj_mkvmaker/services/app_shell.dart';
|
||||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||||
import 'package:jj_mkvmaker/services/secret_store.dart';
|
import 'package:jj_mkvmaker/services/secret_store.dart';
|
||||||
import 'package:jj_mkvmaker/ui/dav_external.dart';
|
import 'package:jj_mkvmaker/ui/dav_external.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/migration_notice.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
class _Shell extends NoopShell {
|
class _Shell extends NoopShell {
|
||||||
|
|
@ -104,4 +105,37 @@ void main() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('116: 예전 판의 "권한 안내 보임" 표시는 읽지 않는다 (권한이 없으면 이 판에서 한 번 더 안내)', () {
|
||||||
|
expect(AppSettings.fromJson({'allFilesHintShown': true}).allFilesHintShown, isFalse);
|
||||||
|
expect(AppSettings.fromJson((AppSettings()..allFilesHintShown = true).toJson()).allFilesHintShown, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('126: "바뀐 기본 설정" 창을 닫으면 바로 저장 - 강제 종료 뒤 켜도 다시 뜨지 않음', (tester) async {
|
||||||
|
final dir = Directory.systemTemp.createTempSync('jj_migr_');
|
||||||
|
addTearDown(() => dir.deleteSync(recursive: true));
|
||||||
|
final file = p.join(dir.path, 'settings.json');
|
||||||
|
// 예전 판이 남긴 설정 (두 창 배치 '화면 모양 따라')
|
||||||
|
File(file).writeAsStringSync('{"explorerOrientation": "auto", "explorerV2": true, "orientationV2": true}');
|
||||||
|
final store = SettingsStore(file, MemorySecretStore());
|
||||||
|
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()),
|
||||||
|
settingsStore: store);
|
||||||
|
await tester.runAsync(c.init);
|
||||||
|
expect(c.settings.migrated, contains('explorerOrientation'));
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox())));
|
||||||
|
final ctx = tester.element(find.byType(SizedBox));
|
||||||
|
unawaited(showMigrationNotice(ctx, c));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('이번 업데이트로 바뀐 기본 설정'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('확인'));
|
||||||
|
// 파일 쓰기 (진짜 입출력) 가 끝나게 몇 번 돌려 준다
|
||||||
|
for (var i = 0; i < 20; i++) {
|
||||||
|
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
// 강제 종료 뒤 다시 켬 = 파일을 새로 읽음
|
||||||
|
final again = await tester.runAsync(() => SettingsStore(file, MemorySecretStore()).load());
|
||||||
|
expect(again!.migrated, isEmpty);
|
||||||
|
expect(again.explorerOrientation, 'side');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,346 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||||
|
import 'package:jj_mkvmaker/app/master_lock.dart';
|
||||||
|
import 'package:jj_mkvmaker/app/settings.dart';
|
||||||
|
import 'package:jj_mkvmaker/core/webdav.dart';
|
||||||
|
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||||
|
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
||||||
|
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||||
|
import 'package:jj_mkvmaker/services/secret_store.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/master_prompt.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/settings_page.dart';
|
||||||
|
|
||||||
|
String hex(List<int> b) => b.map((x) => x.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('124: 해시', () {
|
||||||
|
test('PBKDF2-HMAC-SHA256 이 알려진 값과 같다 (RFC 6070 방식 시험 값)', () {
|
||||||
|
expect(hex(pbkdf2Sha256(utf8.encode('password'), utf8.encode('salt'), 1, 32)),
|
||||||
|
'120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b');
|
||||||
|
expect(hex(pbkdf2Sha256(utf8.encode('password'), utf8.encode('salt'), 2, 32)),
|
||||||
|
'ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43');
|
||||||
|
expect(hex(pbkdf2Sha256(utf8.encode('password'), utf8.encode('salt'), 4096, 32)),
|
||||||
|
'c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a');
|
||||||
|
// 64 바이트보다 긴 열쇠 · 32 바이트보다 긴 결과
|
||||||
|
expect(hex(pbkdf2Sha256(utf8.encode('passwordPASSWORDpassword'), utf8.encode('saltSALTsaltSALTsaltSALTsaltSALTsalt'),
|
||||||
|
4096, 40)),
|
||||||
|
'348c89dbcbd32b2f32d814b8116e84cf2b17347ebc1800181c4e2a1fb8dd53e1c635518c7dac47e9');
|
||||||
|
// 64 바이트보다 긴 비밀번호 (열쇠를 먼저 해시) · 딱 64 바이트 - 값은 Python hashlib.pbkdf2_hmac 으로 구함
|
||||||
|
expect(hex(pbkdf2Sha256(utf8.encode('긴비밀번호' * 10), utf8.encode('saltSALT'), 1000, 32)),
|
||||||
|
'1b2d0e02ae0a189fe6958ca9b786239b9b03c65c9c46276a0c1a673f06a6df7c');
|
||||||
|
expect(hex(pbkdf2Sha256(utf8.encode('x' * 64), utf8.encode('salt'), 3, 32)),
|
||||||
|
'0582d52c2133ec90a168409527adb1a18782d00151ca2939679a72dedca074b5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('원문은 남지 않고, 솔트가 달라 같은 비밀번호도 해시가 다르다 · 맞는 것만 통과', () {
|
||||||
|
final a = PasswordHash.make('내 비밀번호', iterations: 1000);
|
||||||
|
final b = PasswordHash.make('내 비밀번호', iterations: 1000);
|
||||||
|
expect(a, isNot(contains('내 비밀번호')));
|
||||||
|
expect(a, startsWith('pbkdf2-sha256\$1000\$'));
|
||||||
|
expect(a, isNot(b));
|
||||||
|
expect(PasswordHash.verify('내 비밀번호', a), isTrue);
|
||||||
|
expect(PasswordHash.verify('내 비밀번호 ', a), isFalse);
|
||||||
|
expect(PasswordHash.verify('x', 'garbage'), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('124: 마스터 · 최상', () {
|
||||||
|
late MemorySecretStore secrets;
|
||||||
|
late DateTime clock;
|
||||||
|
MasterLock make() => MasterLock(secrets, now: () => clock);
|
||||||
|
setUp(() {
|
||||||
|
secrets = MemorySecretStore();
|
||||||
|
clock = DateTime(2026, 10, 9, 12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('정하기 · 안전 저장소에 해시만 · 둘이 같으면 못 정함 · 다시 읽기', () async {
|
||||||
|
final l = make();
|
||||||
|
expect(await l.setMaster('m-pass'), isTrue);
|
||||||
|
expect(await l.setSuper('m-pass'), isFalse, reason: '마스터와 같음');
|
||||||
|
expect(await l.setSuper('s-pass'), isTrue);
|
||||||
|
expect(await l.setMaster('s-pass'), isFalse, reason: '최상과 같음');
|
||||||
|
expect(secrets.values[MasterLock.masterKey], startsWith('pbkdf2-sha256\$'));
|
||||||
|
expect(secrets.values.values.any((v) => v.contains('m-pass') || v.contains('s-pass')), isFalse);
|
||||||
|
final again = make();
|
||||||
|
await again.load();
|
||||||
|
expect((again.hasMaster, again.hasSuper, again.unlocked), (true, true, false));
|
||||||
|
expect(await again.check('m-pass'), MasterInput.master);
|
||||||
|
expect(again.unlocked, isTrue);
|
||||||
|
final third = make();
|
||||||
|
await third.load();
|
||||||
|
expect(await third.check('s-pass'), MasterInput.superPassword);
|
||||||
|
expect(third.unlocked, isFalse, reason: '최상은 초기화 창으로 (풀지는 않음)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('묻는 때: 물어보지 않기 · 처음 시작 시 · 쓸 때 / 풀면 앱을 끌 때까지 묻지 않음', () async {
|
||||||
|
final l = make();
|
||||||
|
expect(l.needsPrompt(), isFalse, reason: '마스터가 없으면 묻지 않음');
|
||||||
|
await l.setMaster('m');
|
||||||
|
final x = make();
|
||||||
|
await x.load();
|
||||||
|
expect(x.ask, 'onUse', reason: '처음 정하면 쓸 때 묻기');
|
||||||
|
await x.setAsk('never');
|
||||||
|
expect((x.needsPrompt(startup: true), x.needsPrompt()), (false, false));
|
||||||
|
await x.setAsk('startup');
|
||||||
|
expect((x.needsPrompt(startup: true), x.needsPrompt()), (true, true));
|
||||||
|
await x.setAsk('onUse');
|
||||||
|
expect((x.needsPrompt(startup: true), x.needsPrompt()), (false, true));
|
||||||
|
await x.check('m');
|
||||||
|
expect(x.needsPrompt(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('129: 묻는 때는 안전 저장소에 - 값이 없거나 틀리면 쓸 때 묻기 (설정 파일을 지우거나 고쳐도 자물쇠가 꺼지지 않음)', () async {
|
||||||
|
final l = make();
|
||||||
|
await l.setMaster('m');
|
||||||
|
await l.setAsk('never');
|
||||||
|
expect(secrets.values[MasterLock.askKey], 'never');
|
||||||
|
// 사용자가 고른 '물어보지 않기' 는 그대로
|
||||||
|
final a = make();
|
||||||
|
await a.load();
|
||||||
|
expect(a.ask, 'never');
|
||||||
|
// 값이 사라지거나 (예전 판 보관본 · 앱 설정 전체 초기화) 망가지면 쓸 때 묻기
|
||||||
|
secrets.values.remove(MasterLock.askKey);
|
||||||
|
final b = make();
|
||||||
|
await b.load();
|
||||||
|
expect(b.ask, 'onUse');
|
||||||
|
secrets.values[MasterLock.askKey] = 'garbage';
|
||||||
|
final c = make();
|
||||||
|
await c.load();
|
||||||
|
expect(c.ask, 'onUse');
|
||||||
|
// 마스터를 끄면 묻지 않음 · 값도 지움
|
||||||
|
await c.clearMaster();
|
||||||
|
expect((c.ask, secrets.values.containsKey(MasterLock.askKey)), ('never', false));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('128: 한 번 취소하면 자동 · 배경 호출은 다시 묻지 않고, 사용자가 직접 연 곳 (force) 만 다시 묻는다', () async {
|
||||||
|
final l = make();
|
||||||
|
await l.setMaster('m');
|
||||||
|
final x = make();
|
||||||
|
await x.load();
|
||||||
|
var shown = 0;
|
||||||
|
var answer = false;
|
||||||
|
MasterLock.prompt = (_) async {
|
||||||
|
shown++;
|
||||||
|
if (answer) await x.check('m');
|
||||||
|
return answer;
|
||||||
|
};
|
||||||
|
addTearDown(() => MasterLock.prompt = null);
|
||||||
|
expect(await x.ensure(), isFalse); // 배경: 처음엔 묻고 취소
|
||||||
|
expect(shown, 1);
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
expect(await x.ensure(), isFalse); // 30초마다 와도 다시 띄우지 않음
|
||||||
|
}
|
||||||
|
expect(shown, 1);
|
||||||
|
answer = true;
|
||||||
|
expect(await x.ensure(force: true), isTrue); // 사용자가 직접 누름
|
||||||
|
expect(shown, 2);
|
||||||
|
expect(await x.ensure(), isTrue);
|
||||||
|
expect(shown, 2, reason: '풀린 뒤로는 묻지 않음');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('여러 번 틀리면 기다리게 하고, 껐다 켜도 남는다 · 맞히면 풀림', () async {
|
||||||
|
final l = make();
|
||||||
|
await l.setMaster('m');
|
||||||
|
final x = make();
|
||||||
|
await x.load();
|
||||||
|
for (var i = 0; i < 4; i++) {
|
||||||
|
expect(await x.check('bad'), MasterInput.wrong);
|
||||||
|
}
|
||||||
|
expect(x.waiting, isNull);
|
||||||
|
expect(await x.check('bad'), MasterInput.wrong);
|
||||||
|
expect(x.waiting, const Duration(seconds: 30));
|
||||||
|
expect(await x.check('m'), MasterInput.wait, reason: '기다리는 동안은 맞아도 안 받음');
|
||||||
|
final restarted = make();
|
||||||
|
await restarted.load();
|
||||||
|
expect(restarted.waiting, isNotNull);
|
||||||
|
clock = clock.add(const Duration(seconds: 31));
|
||||||
|
expect(await restarted.check('m'), MasterInput.master);
|
||||||
|
expect(restarted.fails, 0);
|
||||||
|
expect(MasterLock.waitFor(6), const Duration(minutes: 1));
|
||||||
|
expect(MasterLock.waitFor(40), const Duration(minutes: 15));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('쓸 때 묻기: 여러 곳에서 동시에 불러도 창은 하나 · 취소하면 막힘', () async {
|
||||||
|
final l = make();
|
||||||
|
await l.setMaster('m');
|
||||||
|
final x = make();
|
||||||
|
await x.load();
|
||||||
|
var shown = 0;
|
||||||
|
MasterLock.prompt = (_) async {
|
||||||
|
shown++;
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
addTearDown(() => MasterLock.prompt = null);
|
||||||
|
final r = await Future.wait([x.ensure(), x.ensure(), x.ensure()]);
|
||||||
|
expect(r, [false, false, false]);
|
||||||
|
expect(shown, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('124: 창', () {
|
||||||
|
AppController controller() {
|
||||||
|
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||||
|
c.settings
|
||||||
|
..webdavServers = [const DavServer(id: 'n', name: 'NAS', url: 'https://nas', user: 'u', password: 'pw')]
|
||||||
|
..openSubtitlesKey = 'k'
|
||||||
|
..openSubtitlesUser = 'ou'
|
||||||
|
..openSubtitlesPassword = 'op';
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BuildContext> host(WidgetTester t) async {
|
||||||
|
await t.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox())));
|
||||||
|
return t.element(find.byType(SizedBox));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> work(WidgetTester t) async {
|
||||||
|
for (var i = 0; i < 30; i++) {
|
||||||
|
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
|
||||||
|
await t.pump();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('마스터 창에 최상을 넣으면 초기화 창 - 처음 체크는 마스터만, 한 번 더 확인 뒤 하고 알림', (t) async {
|
||||||
|
final c = controller();
|
||||||
|
final lock = MasterLock(MemorySecretStore());
|
||||||
|
await t.runAsync(() async {
|
||||||
|
await lock.setMaster('m');
|
||||||
|
await lock.setSuper('s');
|
||||||
|
});
|
||||||
|
lock.unlocked = false;
|
||||||
|
final ctx = await host(t);
|
||||||
|
bool? result;
|
||||||
|
askMaster(ctx, c, lock).then((r) => result = r);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
await t.enterText(find.byType(TextField), 's');
|
||||||
|
await t.tap(find.text('확인'));
|
||||||
|
await work(t);
|
||||||
|
expect(find.text('최상 비밀번호 - 초기화'), findsOneWidget);
|
||||||
|
final boxes = t.widgetList<CheckboxListTile>(find.byType(CheckboxListTile)).map((x) => x.value).toList();
|
||||||
|
expect(boxes, [true, false, false]);
|
||||||
|
await t.tap(find.widgetWithText(FilledButton, '초기화'));
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.text('정말 초기화할까요?'), findsOneWidget);
|
||||||
|
await t.tap(find.widgetWithText(FilledButton, '초기화'));
|
||||||
|
await work(t);
|
||||||
|
expect(find.text('초기화했습니다'), findsOneWidget);
|
||||||
|
expect(find.textContaining('마스터 비밀번호를 초기화했습니다'), findsOneWidget);
|
||||||
|
await t.tap(find.text('닫기'));
|
||||||
|
await work(t);
|
||||||
|
expect(result, isTrue);
|
||||||
|
expect(lock.hasMaster, isFalse);
|
||||||
|
expect(c.settings.webdavServers.single.password, 'pw', reason: '마스터만 골랐으니 비밀번호는 그대로');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('잊었습니다: 저장된 비밀번호 · API 키를 지우고 마스터 초기화 (서버 목록 · 아이디는 남김)', (t) async {
|
||||||
|
final c = controller();
|
||||||
|
final lock = MasterLock(MemorySecretStore());
|
||||||
|
await t.runAsync(() => lock.setMaster('m'));
|
||||||
|
lock.unlocked = false;
|
||||||
|
final ctx = await host(t);
|
||||||
|
askMaster(ctx, c, lock);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
await t.tap(find.text('비밀번호를 잊었습니다'));
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
await t.tap(find.widgetWithText(FilledButton, '지우고 초기화'));
|
||||||
|
await work(t);
|
||||||
|
expect(find.text('초기화했습니다'), findsOneWidget);
|
||||||
|
expect(c.settings.webdavServers.single.password, '');
|
||||||
|
expect(c.settings.webdavServers.single.user, 'u');
|
||||||
|
expect((c.settings.openSubtitlesKey, c.settings.openSubtitlesPassword, c.settings.openSubtitlesUser), ('', '', 'ou'));
|
||||||
|
expect(lock.hasMaster, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('틀리면 다시 묻고 몇 번째인지 보여 줌', (t) async {
|
||||||
|
final c = controller();
|
||||||
|
final lock = MasterLock(MemorySecretStore());
|
||||||
|
await t.runAsync(() => lock.setMaster('m'));
|
||||||
|
lock.unlocked = false;
|
||||||
|
final ctx = await host(t);
|
||||||
|
askMaster(ctx, c, lock);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
await t.enterText(find.byType(TextField), 'bad');
|
||||||
|
await t.tap(find.text('확인'));
|
||||||
|
await work(t);
|
||||||
|
expect(find.text('비밀번호가 틀렸습니다 (1번)'), findsOneWidget);
|
||||||
|
expect(lock.unlocked, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('최상으로 "앱 설정 전체" 를 지우면 처음 상태', () async {
|
||||||
|
final c = controller()..settings.explorerLayout = 'single';
|
||||||
|
await resetAllSettings(c);
|
||||||
|
expect(c.settings.explorerLayout, AppSettings().explorerLayout);
|
||||||
|
expect(c.settings.webdavServers, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('124: 환경 설정은 마스터를 넣기 전에는 잠김 · 넣으면 보안 묶음까지 보임', (t) async {
|
||||||
|
t.view.physicalSize = const Size(1400, 900);
|
||||||
|
t.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(t.view.reset);
|
||||||
|
// 설정 화면의 rsync 확인이 앱 폴더를 묻는다 (시험 환경에는 path_provider 가 없음)
|
||||||
|
final tmp = Directory.systemTemp.createTempSync('jj_master_pp_');
|
||||||
|
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||||
|
const pp = MethodChannel('plugins.flutter.io/path_provider');
|
||||||
|
t.binding.defaultBinaryMessenger.setMockMethodCallHandler(pp, (_) async => tmp.path);
|
||||||
|
addTearDown(() => t.binding.defaultBinaryMessenger.setMockMethodCallHandler(pp, null));
|
||||||
|
final lock = MasterLock(MemorySecretStore());
|
||||||
|
await t.runAsync(() async {
|
||||||
|
await lock.setMaster('m');
|
||||||
|
await lock.setSuper('s');
|
||||||
|
});
|
||||||
|
lock.unlocked = false;
|
||||||
|
MasterLock.instance = lock;
|
||||||
|
addTearDown(() => MasterLock.instance = null);
|
||||||
|
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||||
|
// 앱처럼: 묻는 창은 화면에 (setupMasterLock 이 하는 것)
|
||||||
|
final nav = GlobalKey<NavigatorState>();
|
||||||
|
MasterLock.prompt = (reason) => askMaster(nav.currentContext!, c, lock, reason: reason);
|
||||||
|
addTearDown(() => MasterLock.prompt = null);
|
||||||
|
await t.pumpWidget(MaterialApp(navigatorKey: nav, home: SettingsPage(c: c)));
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.text('마스터 비밀번호'), findsOneWidget, reason: '열자마자 묻는다');
|
||||||
|
await t.tap(find.text('취소'));
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.text('마스터 비밀번호로 잠겨 있습니다'), findsOneWidget);
|
||||||
|
expect(find.text('바뀌면 바로 저장됩니다'), findsNothing);
|
||||||
|
await t.tap(find.text('마스터 비밀번호 넣기'));
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
await t.enterText(find.byType(TextField), 'm');
|
||||||
|
await t.tap(find.text('확인'));
|
||||||
|
for (var i = 0; i < 30; i++) {
|
||||||
|
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
|
||||||
|
await t.pump();
|
||||||
|
}
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.text('마스터 비밀번호로 잠겨 있습니다'), findsNothing);
|
||||||
|
expect(find.text('최상 비밀번호'), findsOneWidget);
|
||||||
|
expect(find.text('마스터 비밀번호를 묻는 때'), findsOneWidget);
|
||||||
|
// 132: 넓은 화면에서도 최상의 [끄기] 가 보이고 (잘리지 않음), 지금 비밀번호 · 확인을 거쳐 끈다
|
||||||
|
final offs = find.widgetWithText(OutlinedButton, '끄기');
|
||||||
|
expect(offs, findsNWidgets(2), reason: '마스터 · 최상 모두');
|
||||||
|
await t.ensureVisible(offs.last);
|
||||||
|
await t.tap(offs.last);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
await t.enterText(find.byType(TextField).last, 'm');
|
||||||
|
await t.tap(find.text('확인'));
|
||||||
|
for (var i = 0; i < 30; i++) {
|
||||||
|
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
|
||||||
|
await t.pump();
|
||||||
|
}
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.text('최상 비밀번호를 끌까요?'), findsOneWidget);
|
||||||
|
await t.tap(find.widgetWithText(FilledButton, '끄기'));
|
||||||
|
for (var i = 0; i < 10; i++) {
|
||||||
|
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
|
||||||
|
await t.pump();
|
||||||
|
}
|
||||||
|
expect(lock.hasSuper, isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -131,7 +131,10 @@ void main() {
|
||||||
expect(LiveSync.activeNow(pair), isFalse);
|
expect(LiveSync.activeNow(pair), isFalse);
|
||||||
c.settings.liveSyncPairs = [pair];
|
c.settings.liveSyncPairs = [pair];
|
||||||
final live = LiveSync(c)..start();
|
final live = LiveSync(c)..start();
|
||||||
await Future<void>.delayed(const Duration(seconds: 4)); // 모아서 3초 뒤
|
// 모아서 3초 뒤 (컴퓨터가 바쁘면 늦을 수 있어 정해진 4초가 아니라 될 때까지 - 최대 20초)
|
||||||
|
for (var i = 0; i < 80 && (live.pending[LiveSync.keyOf(pair)]?.length ?? 0) < 2; i++) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||||
|
}
|
||||||
expect(live.pending[LiveSync.keyOf(pair)], hasLength(2));
|
expect(live.pending[LiveSync.keyOf(pair)], hasLength(2));
|
||||||
expect(Directory(target).existsSync(), isFalse); // 일정 밖: 맞추지 않음
|
expect(Directory(target).existsSync(), isFalse); // 일정 밖: 맞추지 않음
|
||||||
// 지금 맞추기는 늘 된다
|
// 지금 맞추기는 늘 된다
|
||||||
|
|
|
||||||
|
|
@ -80,22 +80,22 @@ void main() {
|
||||||
await open(t, c, src);
|
await open(t, c, src);
|
||||||
expect(find.text('1 / 3'), findsOneWidget);
|
expect(find.text('1 / 3'), findsOneWidget);
|
||||||
await tapAt(t, 850);
|
await tapAt(t, 850);
|
||||||
expect(find.text('2-1 / 3'), findsOneWidget);
|
expect(find.text('2-1 / 3장'), findsOneWidget);
|
||||||
expect(src.widths[1], contains(1800), reason: '반쪽이 화면 가득이어도 흐리지 않게');
|
expect(src.widths[1], contains(1800), reason: '반쪽이 화면 가득이어도 흐리지 않게');
|
||||||
await tapAt(t, 850);
|
await tapAt(t, 850);
|
||||||
expect(find.text('2-2 / 3'), findsOneWidget);
|
expect(find.text('2-2 / 3장'), findsOneWidget);
|
||||||
await tapAt(t, 850);
|
await tapAt(t, 850);
|
||||||
expect(find.text('3 / 3'), findsOneWidget);
|
expect(find.text('3 / 3장'), findsOneWidget);
|
||||||
await tapAt(t, 50);
|
await tapAt(t, 50);
|
||||||
expect(find.text('2-2 / 3'), findsOneWidget);
|
expect(find.text('2-2 / 3장'), findsOneWidget);
|
||||||
|
|
||||||
// 버튼: 자동 → 켜기 (모든 장) → 끄기 → 자동
|
// 버튼: 자동 → 켜기 (모든 장) → 끄기 → 자동
|
||||||
await t.tap(find.byTooltip('두 쪽 나눠 보기: 자동 - 가로로 긴 그림만 (누르면 켜기)'));
|
await t.tap(find.byTooltip('두 쪽 나눠 보기: 자동 - 가로로 긴 그림만 (누르면 켜기)'));
|
||||||
await t.pumpAndSettle();
|
await t.pumpAndSettle();
|
||||||
expect(c.settings.readerSplit, 'on');
|
expect(c.settings.readerSplit, 'on');
|
||||||
expect(find.text('2-2 / 3'), findsOneWidget, reason: '보던 쪽 그대로');
|
expect(find.text('2-2 / 3장'), findsOneWidget, reason: '보던 쪽 그대로');
|
||||||
await tapAt(t, 850);
|
await tapAt(t, 850);
|
||||||
expect(find.text('3-1 / 3'), findsOneWidget);
|
expect(find.text('3-1 / 3장'), findsOneWidget);
|
||||||
await t.tap(find.byTooltip('두 쪽 나눠 보기: 켜기 (누르면 끄기)'));
|
await t.tap(find.byTooltip('두 쪽 나눠 보기: 켜기 (누르면 끄기)'));
|
||||||
await t.pumpAndSettle();
|
await t.pumpAndSettle();
|
||||||
expect(c.settings.readerSplit, 'off');
|
expect(c.settings.readerSplit, 'off');
|
||||||
|
|
@ -109,11 +109,11 @@ void main() {
|
||||||
final c = controller()..settings.readerRtl = true;
|
final c = controller()..settings.readerRtl = true;
|
||||||
final src = await source(t, [(200, 100)]);
|
final src = await source(t, [(200, 100)]);
|
||||||
await open(t, c, src);
|
await open(t, c, src);
|
||||||
expect(find.text('1-1 / 1'), findsOneWidget);
|
expect(find.text('1-1 / 1장'), findsOneWidget);
|
||||||
Align half() => t.widget<Align>(find.descendant(of: find.byType(ClipRect), matching: find.byType(Align)).first);
|
Align half() => t.widget<Align>(find.descendant(of: find.byType(ClipRect), matching: find.byType(Align)).first);
|
||||||
expect(half().alignment, Alignment.centerRight);
|
expect(half().alignment, Alignment.centerRight);
|
||||||
await tapAt(t, 50); // 만화: 왼쪽 = 다음
|
await tapAt(t, 50); // 만화: 왼쪽 = 다음
|
||||||
expect(find.text('1-2 / 1'), findsOneWidget);
|
expect(find.text('1-2 / 1장'), findsOneWidget);
|
||||||
expect(half().alignment, Alignment.centerLeft);
|
expect(half().alignment, Alignment.centerLeft);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -126,7 +126,7 @@ void main() {
|
||||||
await t.pumpAndSettle();
|
await t.pumpAndSettle();
|
||||||
expect(t.widget<RotatedBox>(find.descendant(of: find.byType(FittedBox), matching: find.byType(RotatedBox)).first)
|
expect(t.widget<RotatedBox>(find.descendant(of: find.byType(FittedBox), matching: find.byType(RotatedBox)).first)
|
||||||
.quarterTurns, 1);
|
.quarterTurns, 1);
|
||||||
expect(find.text('1-1 / 2'), findsOneWidget, reason: '돌리니 가로로 길어져 반씩');
|
expect(find.text('1-1 / 2장'), findsOneWidget, reason: '돌리니 가로로 길어져 반씩');
|
||||||
await t.tap(find.byTooltip('반대로 90° 회전'));
|
await t.tap(find.byTooltip('반대로 90° 회전'));
|
||||||
await t.pumpAndSettle();
|
await t.pumpAndSettle();
|
||||||
expect(find.text('1 / 2'), findsOneWidget);
|
expect(find.text('1 / 2'), findsOneWidget);
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ void main() {
|
||||||
try {
|
try {
|
||||||
expect(hasRecycleBin(f.path), isTrue); // 임시 폴더는 고정 디스크
|
expect(hasRecycleBin(f.path), isTrue); // 임시 폴더는 고정 디스크
|
||||||
expect(moveToRecycleBin(f.path), RecycleResult.recycled);
|
expect(moveToRecycleBin(f.path), RecycleResult.recycled);
|
||||||
|
// 수를 세지 않고 휴지통 안의 정보 파일 ($I, 원래 경로) 로 확인할 수 있다
|
||||||
|
expect(recycledInfoExists(f.absolute.path, since: DateTime.now().subtract(const Duration(minutes: 1))), isTrue);
|
||||||
expect(f.existsSync(), isFalse);
|
expect(f.existsSync(), isFalse);
|
||||||
expect(moveToRecycleBin(d.path), RecycleResult.recycled);
|
expect(moveToRecycleBin(d.path), RecycleResult.recycled);
|
||||||
expect(d.existsSync(), isFalse);
|
expect(d.existsSync(), isFalse);
|
||||||
|
|
@ -51,6 +53,18 @@ void main() {
|
||||||
}
|
}
|
||||||
}, skip: !Platform.isWindows);
|
}, skip: !Platform.isWindows);
|
||||||
|
|
||||||
|
test(r'휴지통 정보 파일 ($I) 에서 원래 경로 읽기 - Windows 10 이상 (판 2) · 예전 (판 1)', () {
|
||||||
|
List<int> le(int v, int n) => [for (var i = 0; i < n; i++) v >> (8 * i) & 0xff];
|
||||||
|
List<int> utf16(String s) => [for (final u in s.codeUnits) ...le(u, 2)];
|
||||||
|
const path = r'C:\영상\a b.mkv';
|
||||||
|
final v2 = [...le(2, 8), ...le(1234, 8), ...le(0, 8), ...le(path.length + 1, 4), ...utf16(path), 0, 0];
|
||||||
|
expect(recycledInfoPath(v2), path);
|
||||||
|
final v1 = [...le(1, 8), ...le(1234, 8), ...le(0, 8), ...utf16(path), ...List.filled(520 - path.length * 2, 0)];
|
||||||
|
expect(recycledInfoPath(v1), path);
|
||||||
|
expect(recycledInfoPath([9, 9, 9]), isNull);
|
||||||
|
expect(recycledInfoPath([...le(7, 8), ...List.filled(40, 0)]), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
test('94: 휴지통이 없는 곳 (네트워크 공유 · 없는 드라이브) 은 휴지통으로 보내지 않는다 · 98: 이유는 읽을 수 있는 말', () {
|
test('94: 휴지통이 없는 곳 (네트워크 공유 · 없는 드라이브) 은 휴지통으로 보내지 않는다 · 98: 이유는 읽을 수 있는 말', () {
|
||||||
expect(hasRecycleBin(r'\\NAS\share\a.mkv'), isFalse);
|
expect(hasRecycleBin(r'\\NAS\share\a.mkv'), isFalse);
|
||||||
// 쓰지 않는 드라이브 문자
|
// 쓰지 않는 드라이브 문자
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/browser_page.dart' show deleteExportedCookies;
|
||||||
|
import 'package:jj_mkvmaker/core/file_ops.dart' show SourceUnreadableException;
|
||||||
|
import 'package:jj_mkvmaker/app/settings.dart';
|
||||||
|
import 'package:jj_mkvmaker/core/download_detect.dart';
|
||||||
|
import 'package:jj_mkvmaker/core/secret_gate.dart';
|
||||||
|
import 'package:jj_mkvmaker/core/webdav.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/webdav_settings.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
import 'support/dav_server.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('124: 저장된 비밀번호를 쓰기 전의 문', () {
|
||||||
|
late Directory tmp;
|
||||||
|
late TestDavServer server;
|
||||||
|
setUp(() async {
|
||||||
|
tmp = Directory.systemTemp.createTempSync('jj_gate_');
|
||||||
|
File(p.join(tmp.path, 'a.txt')).writeAsStringSync('a');
|
||||||
|
server = TestDavServer(tmp);
|
||||||
|
await server.start();
|
||||||
|
});
|
||||||
|
tearDown(() async {
|
||||||
|
SecretGate.check = null;
|
||||||
|
await server.stop();
|
||||||
|
tmp.deleteSync(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('마스터를 넣지 않으면 (문이 닫힘) 비밀번호로 접속하지 않는다 · 열리면 접속', () async {
|
||||||
|
var asked = 0;
|
||||||
|
var allow = false;
|
||||||
|
SecretGate.check = (_) async {
|
||||||
|
asked++;
|
||||||
|
return allow;
|
||||||
|
};
|
||||||
|
final c = DavClient(DavServer(id: 'n', name: 'n', url: server.url, user: 'user', password: 'pass'));
|
||||||
|
// 128: "읽지 못함" 이 아니라 "마스터를 기다리는 중" 으로 구분된다
|
||||||
|
await expectLater(c.list(''), throwsA(isA<DavLockedException>().having((e) => e.message, 'message', secretGateMessage)));
|
||||||
|
try {
|
||||||
|
await c.list('');
|
||||||
|
} catch (e) {
|
||||||
|
expect(SourceUnreadableException('dav://n/', cause: e).locked, isTrue);
|
||||||
|
}
|
||||||
|
expect(SourceUnreadableException('dav://n/', cause: const DavException('401')).locked, isFalse);
|
||||||
|
expect(asked, 2, reason: '부를 때마다 문을 지난다');
|
||||||
|
allow = true;
|
||||||
|
expect((await c.list('')).map((e) => e.rel), anyElement(contains('a.txt')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('비밀번호가 없는 서버는 묻지 않는다', () async {
|
||||||
|
var asked = 0;
|
||||||
|
SecretGate.check = (_) async {
|
||||||
|
asked++;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
final c = DavClient(DavServer(id: 'o', name: 'o', url: server.url));
|
||||||
|
try {
|
||||||
|
await c.list(''); // 시험 서버는 로그인을 요구해 401 - 묻지 않고 그냥 접속해 본 것만 확인
|
||||||
|
} on DavException catch (e) {
|
||||||
|
expect(e, isNot(isA<DavLockedException>()));
|
||||||
|
}
|
||||||
|
expect(asked, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('124-1: 지우려는 서버를 쓰는 실시간 동기화 · 기억된 작업을 찾는다', () {
|
||||||
|
final s = AppSettings()
|
||||||
|
..liveSyncPairs = [
|
||||||
|
LiveSyncPair(r'C:\영상', 'dav://nas/백업'),
|
||||||
|
LiveSyncPair(r'C:\a', r'D:\b'),
|
||||||
|
LiveSyncPair('dav://other/x', r'C:\y'),
|
||||||
|
]
|
||||||
|
..copyTasks = [
|
||||||
|
CopyTask(id: 't', sources: const ['dav://nas/사진'], dest: r'C:\사진', method: 'builtin', options: '', once: false),
|
||||||
|
];
|
||||||
|
final users = davServerUsers(s, 'nas');
|
||||||
|
expect(users, hasLength(2));
|
||||||
|
expect(users.first, contains('실시간 동기화'));
|
||||||
|
expect(users.last, contains('기억된 작업'));
|
||||||
|
expect(davServerUsers(s, 'none'), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('56: http:// 주소는 암호화 안 됨으로 알린다', () {
|
||||||
|
expect(isPlainHttp('http://nas.local/dav'), isTrue);
|
||||||
|
expect(isPlainHttp(' HTTP://x'), isTrue);
|
||||||
|
expect(isPlainHttp('https://nas.local/dav'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('53: 브라우저 쿠키', () {
|
||||||
|
tearDown(() {
|
||||||
|
CookieExport.sites = loginCookieDomains;
|
||||||
|
CookieExport.enabled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('고른 사이트의 쿠키만 넘긴다 (처음은 모두)', () {
|
||||||
|
expect(AppSettings().loginCookieSites, loginCookieDomains);
|
||||||
|
expect(isLoginCookieDomain('.instagram.com'), isTrue);
|
||||||
|
CookieExport.sites = ['youtube.com', 'google.com'];
|
||||||
|
expect(isLoginCookieDomain('.youtube.com'), isTrue);
|
||||||
|
expect(isLoginCookieDomain('.instagram.com'), isFalse);
|
||||||
|
expect(isLoginCookieDomain('netflix.com'), isFalse);
|
||||||
|
final b = AppSettings.fromJson((AppSettings()..loginCookieSites = ['x.com']).toJson());
|
||||||
|
expect(b.loginCookieSites, ['x.com']);
|
||||||
|
expect(AppSettings.fromJson({'loginCookieSites': ['x.com', 'evil.com']}).loginCookieSites, ['x.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('앱 안 브라우저 쿠키를 쓰던 사람에게 넘기는 사이트가 넓어진 것을 한 번 알린다', () {
|
||||||
|
expect(AppSettings.fromJson({'ytCookiesBrowser': internalBrowserCookies}).migrated, contains('cookieScope'));
|
||||||
|
expect(AppSettings.fromJson({'ytCookiesBrowser': ''}).migrated, isNot(contains('cookieScope')));
|
||||||
|
expect(AppSettings.fromJson({}).migrated, isNot(contains('cookieScope')), reason: '새로 설치');
|
||||||
|
expect(AppSettings.fromJson(AppSettings().toJson()).migrated, isNot(contains('cookieScope')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('131: 내보낸 쿠키 파일 지우기', () async {
|
||||||
|
final dir = Directory.systemTemp.createTempSync('jj_cookie_');
|
||||||
|
addTearDown(() => dir.deleteSync(recursive: true));
|
||||||
|
final f = File(p.join(dir.path, CookieExport.fileName))..writeAsStringSync('# Netscape HTTP Cookie File');
|
||||||
|
File('${f.path}.tmp').writeAsStringSync('x');
|
||||||
|
await deleteExportedCookies(dir.path);
|
||||||
|
expect(f.existsSync(), isFalse);
|
||||||
|
expect(File('${f.path}.tmp').existsSync(), isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||||
|
import 'package:jj_mkvmaker/core/secret_gate.dart';
|
||||||
|
import 'package:jj_mkvmaker/core/webdav.dart';
|
||||||
|
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||||
|
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
||||||
|
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/webdav_settings.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('127: 비밀번호가 저장된 서버를 고치려면 먼저 마스터 (넣지 않으면 창이 열리지 않아 비밀번호가 보이지 않음)', (t) async {
|
||||||
|
var allow = false;
|
||||||
|
var forced = false;
|
||||||
|
SecretGate.check = (force) async {
|
||||||
|
forced = force;
|
||||||
|
return allow;
|
||||||
|
};
|
||||||
|
addTearDown(() => SecretGate.check = null);
|
||||||
|
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||||
|
await t.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox())));
|
||||||
|
final ctx = t.element(find.byType(SizedBox));
|
||||||
|
const old = DavServer(id: 'n', name: 'NAS', url: 'https://nas', user: 'u', password: 'secret-pw');
|
||||||
|
DavServer? r = const DavServer(id: 'x', name: 'x', url: 'x');
|
||||||
|
editDavServer(ctx, c, old: old).then((v) => r = v);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(r, isNull);
|
||||||
|
expect(forced, isTrue, reason: '사용자가 직접 연 것 - 취소한 적이 있어도 묻는다');
|
||||||
|
expect(find.byType(AlertDialog), findsNothing);
|
||||||
|
expect(find.text('secret-pw', findRichText: true), findsNothing);
|
||||||
|
allow = true;
|
||||||
|
editDavServer(ctx, c, old: old);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.text('WebDAV 서버 고치기'), findsOneWidget);
|
||||||
|
await t.tap(find.text('취소'));
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
// 비밀번호가 없는 서버는 묻지 않는다
|
||||||
|
allow = false;
|
||||||
|
forced = false;
|
||||||
|
editDavServer(ctx, c, old: const DavServer(id: 'o', name: 'o', url: 'https://o'));
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.text('WebDAV 서버 고치기'), findsOneWidget);
|
||||||
|
expect(forced, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue