From 94ecd7b25e471fd9decba645f4a824178e6d0e94 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:20:24 +0900 Subject: [PATCH] =?UTF-8?q?WIP=2089=20Wi-Fi=20=EA=B3=B5=EC=9C=A0:=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B8=20=ED=8F=B4=EB=8D=94=EB=A5=BC=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20Wi-Fi=20=EC=9D=98=20=EB=8B=A4=EB=A5=B8=20=EA=B8=B0?= =?UTF-8?q?=EA=B8=B0=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B3=B4=EA=B8=B0=20=C2=B7=20=EB=B0=9B=EA=B8=B0=20?= =?UTF-8?q?(=EC=9D=BD=EA=B8=B0=20=EC=A0=84=EC=9A=A9)=20(=EA=B2=80=ED=86=A0?= =?UTF-8?q?=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 결정 (10/9): 기본 꺼짐 · 읽기 전용 · 접속 비밀번호. 관리자 지시 (10/17) 대로: - 앱 안 작은 HTTP 서버 (dart:io, 새 서버 패키지 없음). 켤 때마다 6자리 코드를 새로 만들고 주소 · QR · 코드를 화면에 - 고른 폴더 밖은 막음: ../ · 역슬래시 · 드라이브 · ':' · 링크 · junction (목록에도 안 보임), 실제 경로가 폴더 안인지 다시 확인 - GET · HEAD 만 (그 밖은 405). 올리기 · 지우기 · 이름 바꾸기 없음 - 저절로 끄기: 설정 10 / 30 (처음 값) / 60 / 120분 / 끄지 않음, [+30분]. 앱을 끄면 함께 꺼짐 - 접속 기록은 화면에만 (파일에 남기지 않음). 작업 기록에는 켜고 끈 것만, 코드 · 주소 없음. 로그인 뒤 주소에서 코드를 뺌 - Android: 켜져 있는 동안 작업 알림 서비스 ("Wi-Fi 공유 켜짐" - 코드 · 주소는 잠금 화면에 보일 수 있어 넣지 않음) - 탐색기: 폴더 길게 누르기 [Wi-Fi 로 공유] (이 기기의 폴더만) · ⋮ 메뉴 · 버튼 구성, 켜져 있는 동안 위에 "Wi-Fi 공유 중" 줄 ([끄기]) 내 판단 (보안): - 코드를 틀리면 한 기기는 5번에 1분 막고, 모두 30번이면 공유를 끔 (6자리 무작위 대입 막기) - 사설망 · 링크 로컬 · Tailscale (100.64/10) · 이 기기에서 온 요청만 받음 - 공유한 파일은 격리해 보냄 (CSP sandbox · nosniff, HTML · SVG · JS 는 내려받기로). 앱 화면에도 스크립트 없음 (CSP) - 쿠키는 HttpOnly · SameSite=Strict, 코드 비교는 시간 차가 없게 라이선스: qr 3.0.2 (BSD-3-Clause, 원래 간접 의존성) 를 직접 의존성으로 - 두 고지 · licenses_test. 시험: wifi_share_test 8 (401 · 429 · 끄기 · 403/404 · 링크 · Range · 격리 · 저절로 끄기 · 사설망), wifi_share_page_test (412 폭), keep_alive · licenses. analyze 문제 없음, l10n 빠짐 0, test_temp_check: jj_wifi* 흔적 0 (같은 때 cd 의 explorer 시험 폴더가 섞여 보임). Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 41 ++ assets/l10n/ja.json | 41 ++ assets/l10n/zh-Hans.json | 41 ++ assets/licenses/THIRD_PARTY_NOTICES.txt | 2 + .../licenses/THIRD_PARTY_NOTICES_ANDROID.txt | 2 + lib/app/settings.dart | 10 + lib/core/wifi_share.dart | 462 ++++++++++++++++++ lib/platform/android/android_keep_alive.dart | 9 +- lib/ui/explorer_page.dart | 57 ++- lib/ui/wifi_share_page.dart | 262 ++++++++++ pubspec.lock | 2 +- pubspec.yaml | 2 + test/keep_alive_test.dart | 5 + test/licenses_test.dart | 6 + test/wifi_share_page_test.dart | 60 +++ test/wifi_share_test.dart | 204 ++++++++ 16 files changed, 1202 insertions(+), 4 deletions(-) create mode 100644 lib/core/wifi_share.dart create mode 100644 lib/ui/wifi_share_page.dart create mode 100644 test/wifi_share_page_test.dart create mode 100644 test/wifi_share_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index e92ab26..1cd20f3 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -63,6 +63,7 @@ "(현재 방식은 옵션 없음)": "(no options for the built-in method)", "* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)": "* matches any text, ? one character. Separate several with ; (e.g. *.srt; *.ass)", "+ 여기에 새 줄": "+ New line here", + "+30분": "+30 min", "- 감지한 언어": "- detected", "-1 은 아무렇게나": "-1 = random", ".{0} 재생 프로그램 (exe)": ".{0} player (exe)", @@ -196,6 +197,7 @@ "IPv4 · IPv6 주소를 넣으세요 (예: 8.8.4.4)": "Enter an IPv4 or IPv6 address (e.g. 8.8.4.4)", "IPv4 · IPv6 주소를 넣으세요 (예: 8.8.8.8)": "Enter an IPv4 or IPv6 address (e.g. 8.8.8.8)", "JJ_MKVMaker - 다운로드 {0}개": "JJ_MKVMaker - {0} downloads", + "JJ_MKVMaker Wi-Fi 공유": "JJ_MKVMaker Wi-Fi sharing", "JJ_MKVMaker 로 자막 만들기": "Make subtitles with JJ_MKVMaker", "JJ_MKVMaker 로 재생": "Play with JJ_MKVMaker", "JJ_MKVMaker 작업 중": "JJ_MKVMaker is working", @@ -342,6 +344,14 @@ "Whisper base (빠름)": "Whisper base (fast)", "Whisper small (정확)": "Whisper small (accurate)", "Whisper 모델 (OpenAI)": "Whisper models (OpenAI)", + "Wi-Fi 공유": "Wi-Fi sharing", + "Wi-Fi 공유 중 · 코드 {0} · {1}": "Sharing over Wi-Fi · code {0} · {1}", + "Wi-Fi 공유 중 · 코드 {0} · {1}분 남음 · {2}": "Sharing over Wi-Fi · code {0} · {1} min left · {2}", + "Wi-Fi 공유 켜짐": "Wi-Fi sharing on", + "Wi-Fi 공유는 이 기기의 폴더만 할 수 있습니다": "Only folders on this device can be shared over Wi-Fi", + "Wi-Fi 공유를 껐습니다": "Wi-Fi sharing turned off", + "Wi-Fi 공유를 켰습니다": "Wi-Fi sharing turned on", + "Wi-Fi 로 공유": "Share over Wi-Fi", "Windows 가 쓰는 이름 ({0})": "Name reserved by Windows ({0})", "Windows 기본 브라우저": "Windows default browser", "Windows 기본 프로그램": "Windows default program", @@ -532,6 +542,8 @@ "{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "Upscales {0}× and saves new files. Originals and ZIPs stay unchanged. It keeps going after you close the screen, and the job notification shows pages left and the estimated time.", "{0}분": "{0} min", "{0}분 {1}초": "{0} min {1} s", + "{0}분 뒤": "After {0} min", + "{0}분 뒤에 저절로 꺼집니다": "Turns off automatically in {0} min", "{0}시간 {1}분": "{0} h {1} min", "{0}일 안": "within {0} days", "{0}일 지남": "older than {0} days", @@ -585,6 +597,7 @@ "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "Downloaded only if the detected language differs from the subtitle language {0}", "강하게 (0.4)": "Strong (0.4)", "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "Stronger refines more but may drift from the original", + "같은 네트워크에서만 열 수 있습니다": "Can only be opened from the same network", "같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} files that replaced a file with the same name won't be undone.", "같은 이름이 이미 있습니다": "An item with the same name already exists", @@ -659,7 +672,11 @@ "공용 폴더에 받은 파일 (동영상 · 그림 등)": "Files downloaded to shared folders (videos, images, etc.)", "공유": "Share", "공유 (다른 앱으로 보내기)": "Share (send to another app)", + "공유 중": "Sharing", "공유 창을 열 수 없습니다.": "Can't open the share sheet.", + "공유한 기기의 화면에 보이는 6자리 코드를 넣으세요.": "Enter the 6-digit code shown on the sharing device.", + "공유한 폴더 밖이거나 링크라 열 수 없습니다": "Cannot open: outside the shared folder or a link", + "공유할 폴더": "Folder to share", "관리 메뉴": "Manage menu", "관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)": "The Chrome engine can't be used because the app is running as administrator (security)", "관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.": "Only used when running as administrator. When off (default), the Chrome engine isn't started then and Edge is suggested.", @@ -739,6 +756,7 @@ "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "When off, page scripts do not run (fewer ads · trackers, but script-built sites such as YouTube will not show). With the Chrome engine, page translation · video detection also stop.", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "When off, page scripts do not run (fewer ads · trackers, but script-built sites such as YouTube will not show). Page translation · video detection also stop.", "끄면 환경 설정 · 다운로드 목록 · 다른 화면을 보는 동안에도 웹 페이지의 소리가 계속 들립니다": "When off, the web page keeps playing sound while you look at settings, the download list or other screens", + "끄지 않음 (직접 끔)": "Don't turn off (turn off manually)", "끌어 놓기 기본": "Drag and drop default", "끌어다 놓은 항목에 동영상이 없습니다.": "There are no videos in the dropped items.", "끌어서 다른 창으로": "Drag to the other pane", @@ -822,6 +840,7 @@ "다 받은 {0} 은 이미 MKV 목록에 있어 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)": "{0} is already in the MKV list, so it was removed from the download list (the file is kept)", "다 받은 {0} 을 MKV 목록에 넣고 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)": "Added {0} to the MKV list and removed it from the download list (the file is kept)", "다른 곳에서 지웠거나 옮겼을 수 있습니다. 위 폴더로 가 보세요.": "It may have been deleted or moved elsewhere. Try the parent folder.", + "다른 기기의 브라우저에서 이 주소를 여세요": "Open this address in a browser on the other device", "다른 버전 ({0}) 을 쓰다가 v{1} 로 돌아왔습니다. 그 버전은 이 버전의 설정 일부를 모를 수 있습니다.\nv{1} 를 마지막으로 쓸 때 보관해 둔 설정 (동영상 목록 · 즐겨찾기 포함) 으로 되돌릴까요?": "You came back to v{1} after using another version ({0}). That version may not know some of this version's settings.\nRestore the settings kept when you last used v{1} (including the video list and bookmarks)?", "다른 버전 고르기": "Choose another version", "다른 브라우저의 즐겨찾기 가져오기": "Import bookmarks from another browser", @@ -999,6 +1018,7 @@ "등록 중 일부가 실패했습니다.": "Some registrations failed.", "등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "Registered: \"Play with JJ_MKVMaker\" · \"Make subtitles with JJ_MKVMaker\" (Windows 11: Show more options)", "따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "Not covered: the Tor screen (names are looked up inside Tor), IP addresses, home-network names (NAS / PC names without a dot, *.local, Tailscale *.ts.net etc. use the device DNS)", + "또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "Or scan the QR code with the other device's camera (it includes the code)", "라오어": "Lao", "라이선스 원문 보기": "View full license text", "러시아어": "Russian", @@ -1312,6 +1332,7 @@ "빈 폴더 그대로": "Leave empty folders", "빈 폴더 지움 · 원본 폴더는 남김": "Delete empty folders · keep the source folder", "빈 폴더 지움 · 원본 폴더도 지움": "Delete empty folders · delete the source folder too", + "빈 폴더입니다": "This folder is empty", "빠르게": "Fast", "빠른 디코더 (TAESD)": "Fast decoder (TAESD)", "빠른 필터": "Quick filter", @@ -1513,6 +1534,7 @@ "아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "None yet. Videos come in automatically once downloaded (change in Settings).", "아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "Not yet (downloaded when you copy with rsync · or press [Download])", "아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "Still couldn't save. Please try again in a moment", + "아직 접속한 기기가 없습니다": "No device has connected yet", "아직 지원하지 않는 플랫폼입니다: {0}": "This platform is not supported yet: {0}", "아직 허용되지 않았습니다": "Not allowed yet", "안내 닫기": "Close tip", @@ -1579,6 +1601,7 @@ "업데이트로 바뀐 기본값 알림": "Notify about defaults changed by updates", "없는 경로": "path not found", "없는 경로: {0}": "path not found: {0}", + "없는 항목입니다": "No such item", "없음": "None", "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "None: one is created when you first press [Move] in the details", "엔진": "Engine", @@ -1780,6 +1803,7 @@ "이 기기에서는 적게 · 보통 · 많이 가 모두 같습니다: {0} (메모리가 작아 가장 작은 타일)": "On this device Low · Normal · High are all the same: {0} (smallest tile because memory is small)", "이 기기용 파일 없음": "No file for this device", "이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다": "This device's CPU does not support this AI engine", + "이 기기의 Wi-Fi 주소를 찾지 못했습니다. Wi-Fi 에 연결돼 있는지 확인하세요.": "Couldn't find this device's Wi-Fi address. Check that it is connected to Wi-Fi.", "이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "Couldn't save the password because this device's secure storage is unavailable", "이 기기의 웹 뷰로는 Tor 화면을 열 수 없습니다": "The Tor screen cannot be opened with this device's web view", "이 기기의 폴더": "Folder on this device", @@ -1905,7 +1929,10 @@ "일반 오류": "general failure", "일본어": "Japanese", "일시정지": "Pause", + "읽기 전용 · 보기와 받기만 됩니다": "Read-only · view and download only", + "읽기 전용 공유입니다 (보기 · 받기만)": "This share is read-only (view and download only)", "읽기 전용 매체입니다": "Read-only media", + "읽기 전용입니다: 보기와 받기만 되고, 올리기 · 지우기 · 이름 바꾸기는 되지 않습니다. 이 폴더 밖과 링크는 열리지 않습니다.": "Read-only: files can be viewed and downloaded, but not uploaded, deleted or renamed. Anything outside this folder and links cannot be opened.", "읽는 중 · 폴더 {0}개 · {1}": "Reading · {0} folders · {1}", "읽을 수 없습니다": "Cannot read", "읽을 수 없습니다: {0}": "Cannot read: {0}", @@ -1914,6 +1941,7 @@ "읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "The original file that could not be read was not deleted; it is kept here:", "읽지 못한 폴더 {0}개 (권한 · 연결 문제) - 그 안은 찾지 못했습니다": "{0} unreadable folders (permission or connection problem) - not searched inside", "읽지 못한 폴더 {0}개 (크기가 실제보다 작을 수 있음)": "{0} folders couldn't be read (sizes may be smaller than actual)", + "읽지 못했습니다": "Couldn't read", "임시 파일 · 남은 조각 정리": "Clean temporary files · leftovers", "자동": "Auto", "자동 (가로로 긴 그림만)": "Auto (wide images only)", @@ -2033,6 +2061,8 @@ "저장했습니다": "Saved", "저장했습니다: {0}": "Saved: {0}", "저장했습니다: {0} (MKV 에 반영됨)": "Saved: {0} (applied to MKV)", + "저절로 끄기": "Turn off automatically", + "저절로 끄지 않습니다 - 다 쓰면 [끄기] 를 누르세요": "Won't turn off automatically - press [Turn off] when done", "적게 (느림)": "Low (slower)", "적게 (느림): {0}": "Low (slower): {0}", "적용": "Apply", @@ -2045,6 +2075,9 @@ "전체 화면 (F)": "Full screen (F)", "절대 경로": "Absolute path", "점검": "Check", + "접속 QR": "Connection QR code", + "접속 기록 ({0}) - 이 화면에만 보이고 저장하지 않습니다": "Access log ({0}) - shown only on this screen, not saved", + "접속 코드": "Access code", "정렬 기준": "Sort by", "정렬을 폴더마다 기억": "Remember sort per folder", "정리": "Clean up", @@ -2065,6 +2098,7 @@ "정하지 않음. 마스터를 잊으면 저장된 비밀번호를 지우고 마스터를 초기화하는 것만 할 수 있습니다": "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", + "정한 시간이 지나 저절로 껐습니다": "Turned off automatically after the set time", "정해 둠. 마스터 입력 창에 넣으면 초기화 창이 열립니다 (마스터 · 저장된 비밀번호 · 앱 설정 전체 중 고름)": "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", @@ -2090,6 +2124,7 @@ "주말 종일": "Weekends, all day", "주소": "Address", "주소 (http · https · 마그넷) 를 찾지 못했거나 이미 받는 중입니다.": "No address (http · https · magnet) found, or it's already downloading.", + "주소 복사": "Copy address", "주소 이름 (예: youtube.com) 을 찾을 때 물어볼 서버. 직접 지정하면 기기의 DNS 대신 이 서버만 씁니다. 서버에 닿지 않으면 기기 DNS 로 몰래 바꾸지 않고 알립니다.": "The server asked when looking up host names (e.g. youtube.com). When set manually, only this server is used instead of the device DNS. If it cannot be reached, you are told instead of silently switching to the device DNS.", "주소 이름이 너무 깁니다: {0}": "Host name is too long: {0}", "주소 입력 또는 검색": "Enter an address or search", @@ -2220,6 +2255,7 @@ "처음 쓸 때 내려받기 (기본)": "Download on first use (default)", "처음 아이콘 (파랑)": "Original icon (blue)", "처음 찾을 때 끝까지 찾기 전의 결과입니다. 더 찾으려면 다시 찾으세요.": "These results are from before the search finished. Search again to find more.", + "처음 켤 때 Windows 방화벽 창이 뜨면 \"개인 네트워크\" 에 [허용] 을 누르세요.": "If a Windows Firewall window appears the first time, press [Allow] for \"Private networks\".", "처음대로": "Reset to default", "처음부터 정하려면 AI 자막 창의 \"원어 (영상 속 언어)\" 에서 고르세요.": "To set it from the start, choose it in \"Source language (spoken in the video)\" in the AI subtitles window.", "처음으로: {0}": "Back to the first: {0}", @@ -2272,7 +2308,12 @@ "켜면 열 머리 · 정렬 기준에서 바꾼 정렬이 그 폴더에만 쓰입니다. 끄면 모든 폴더가 같은 정렬입니다": "When on, a sort changed from a column header or Sort by applies to that folder only. When off, all folders share one sort", "켜면 휴지통으로 보내 되살릴 수 있습니다 (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)", + "켜지 못했습니다: {0}": "Couldn't turn on: {0}", + "켤 때마다 6자리 코드를 새로 만듭니다. 주소와 코드를 아는 같은 Wi-Fi 의 기기만 이 폴더를 볼 수 있습니다.": "A new 6-digit code is made each time you turn it on. Only devices on the same Wi-Fi that know the address and code can see this folder.", "코덱": "Codec", + "코드가 맞지 않습니다": "The code is incorrect", + "코드를 너무 많이 틀려 공유를 껐습니다 (다른 기기가 코드를 맞히려 했을 수 있습니다)": "Sharing was turned off after too many wrong codes (another device may have tried to guess the code)", + "코드를 여러 번 틀려 잠시 막았습니다. 1분 뒤에 다시 하세요.": "Too many wrong codes - blocked for a moment. Try again in 1 minute.", "쿠키 · 방문 기록 지우기": "Clear cookies and history", "쿠키 · 방문 기록을 지웠습니다.": "Cleared cookies and history.", "쿠키 파일 지우기": "Delete cookie file", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index ed9d71b..5732f98 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -63,6 +63,7 @@ "(현재 방식은 옵션 없음)": "(現在の方式にはオプションなし)", "* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)": "* は任意の文字列、? は 1 文字。複数は ; で区切る (例: *.srt; *.ass)", "+ 여기에 새 줄": "+ ここに新しい行", + "+30분": "+30分", "- 감지한 언어": "- 検出した言語", "-1 은 아무렇게나": "-1 はランダム", ".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)", @@ -196,6 +197,7 @@ "IPv4 · IPv6 주소를 넣으세요 (예: 8.8.4.4)": "IPv4・IPv6 アドレスを入力してください (例: 8.8.4.4)", "IPv4 · IPv6 주소를 넣으세요 (예: 8.8.8.8)": "IPv4・IPv6 アドレスを入力してください (例: 8.8.8.8)", "JJ_MKVMaker - 다운로드 {0}개": "JJ_MKVMaker - ダウンロード {0}個", + "JJ_MKVMaker Wi-Fi 공유": "JJ_MKVMaker Wi-Fi 共有", "JJ_MKVMaker 로 자막 만들기": "JJ_MKVMaker で字幕を作成", "JJ_MKVMaker 로 재생": "JJ_MKVMaker で再生", "JJ_MKVMaker 작업 중": "JJ_MKVMaker 作業中", @@ -342,6 +344,14 @@ "Whisper base (빠름)": "Whisper base(高速)", "Whisper small (정확)": "Whisper small(高精度)", "Whisper 모델 (OpenAI)": "Whisper モデル (OpenAI)", + "Wi-Fi 공유": "Wi-Fi 共有", + "Wi-Fi 공유 중 · 코드 {0} · {1}": "Wi-Fi 共有中 · コード {0} · {1}", + "Wi-Fi 공유 중 · 코드 {0} · {1}분 남음 · {2}": "Wi-Fi 共有中 · コード {0} · 残り {1}分 · {2}", + "Wi-Fi 공유 켜짐": "Wi-Fi 共有オン", + "Wi-Fi 공유는 이 기기의 폴더만 할 수 있습니다": "Wi-Fi 共有できるのはこの端末のフォルダーだけです", + "Wi-Fi 공유를 껐습니다": "Wi-Fi 共有をオフにしました", + "Wi-Fi 공유를 켰습니다": "Wi-Fi 共有をオンにしました", + "Wi-Fi 로 공유": "Wi-Fi で共有", "Windows 가 쓰는 이름 ({0})": "Windows が使う名前 ({0})", "Windows 기본 브라우저": "Windows の既定のブラウザー", "Windows 기본 프로그램": "Windows の既定のプログラム", @@ -532,6 +542,8 @@ "{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "{0} 倍に高解像度化して新しいファイルで保存します。元のファイル・ZIP はそのままです。画面を閉じても続き、作業通知に残りの枚数と予想時間が表示されます。", "{0}분": "{0} 分", "{0}분 {1}초": "{0}分 {1}秒", + "{0}분 뒤": "{0}分後", + "{0}분 뒤에 저절로 꺼집니다": "{0}分後に自動でオフになります", "{0}시간 {1}분": "{0}時間 {1}分", "{0}일 안": "{0} 日以内", "{0}일 지남": "{0} 日以上前", @@ -585,6 +597,7 @@ "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "検出した言語が作成する言語と異なる場合のみダウンロード {0}", "강하게 (0.4)": "強め (0.4)", "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "強いほど大きく整えますが、元と違ってくることがあります", + "같은 네트워크에서만 열 수 있습니다": "同じネットワークからのみ開けます", "같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "同じ名前を上書きしたファイル {0} 件は元に戻しません。", "같은 이름이 이미 있습니다": "同じ名前のものが既にあります", @@ -659,7 +672,11 @@ "공용 폴더에 받은 파일 (동영상 · 그림 등)": "共有フォルダーにダウンロードしたファイル (動画・画像など)", "공유": "共有", "공유 (다른 앱으로 보내기)": "共有 (他のアプリに送る)", + "공유 중": "共有中", "공유 창을 열 수 없습니다.": "共有画面を開けません。", + "공유한 기기의 화면에 보이는 6자리 코드를 넣으세요.": "共有している端末の画面に表示された 6 桁のコードを入力してください。", + "공유한 폴더 밖이거나 링크라 열 수 없습니다": "共有フォルダーの外かリンクのため開けません", + "공유할 폴더": "共有するフォルダー", "관리 메뉴": "管理メニュー", "관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)": "管理者権限で実行中のため Chrome エンジンを使えません (セキュリティ)", "관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.": "管理者権限で実行するときだけ使われます。オフ (既定) の場合、そのときは Chrome エンジンを起動せず Edge をおすすめします。", @@ -739,6 +756,7 @@ "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "オフにするとページのスクリプトを実行しません (広告 · 追跡は減りますが、YouTube のようにスクリプトで作られたサイトは表示されません)。Chrome エンジンではページ翻訳 · 動画の検出も止まります。", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "オフにするとページのスクリプトを実行しません (広告 · 追跡は減りますが、YouTube のようにスクリプトで作られたサイトは表示されません)。ページ翻訳 · 動画の検出も止まります。", "끄면 환경 설정 · 다운로드 목록 · 다른 화면을 보는 동안에도 웹 페이지의 소리가 계속 들립니다": "オフにすると、環境設定・ダウンロード一覧・他の画面を見ている間も Web ページの音が流れ続けます", + "끄지 않음 (직접 끔)": "オフにしない (手動でオフ)", "끌어 놓기 기본": "ドラッグ&ドロップの既定", "끌어다 놓은 항목에 동영상이 없습니다.": "ドロップした項目に動画がありません。", "끌어서 다른 창으로": "ドラッグしてもう一方のウィンドウへ", @@ -822,6 +840,7 @@ "다 받은 {0} 은 이미 MKV 목록에 있어 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)": "ダウンロードした {0} はすでに MKV 一覧にあるため、ダウンロード一覧から外しました (ファイルはそのまま)", "다 받은 {0} 을 MKV 목록에 넣고 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)": "ダウンロードした {0} を MKV 一覧に入れ、ダウンロード一覧から外しました (ファイルはそのまま)", "다른 곳에서 지웠거나 옮겼을 수 있습니다. 위 폴더로 가 보세요.": "別の場所で削除・移動された可能性があります。上のフォルダーへ移動してみてください。", + "다른 기기의 브라우저에서 이 주소를 여세요": "もう一方の端末のブラウザーでこのアドレスを開いてください", "다른 버전 ({0}) 을 쓰다가 v{1} 로 돌아왔습니다. 그 버전은 이 버전의 설정 일부를 모를 수 있습니다.\nv{1} 를 마지막으로 쓸 때 보관해 둔 설정 (동영상 목록 · 즐겨찾기 포함) 으로 되돌릴까요?": "別のバージョン ({0}) を使ったあと v{1} に戻りました。そのバージョンはこのバージョンの設定の一部を知らない可能性があります。\nv{1} を最後に使ったときに保管した設定 (動画リスト · ブックマークを含む) に戻しますか?", "다른 버전 고르기": "別のバージョンを選ぶ", "다른 브라우저의 즐겨찾기 가져오기": "他のブラウザーのブックマークを取り込む", @@ -999,6 +1018,7 @@ "등록 중 일부가 실패했습니다.": "登録の一部に失敗しました。", "등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "登録済み:「JJ_MKVMaker で再生」・「JJ_MKVMaker で字幕を作成」(Windows 11: その他のオプションを表示)", "따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "対象外: Tor 画面 (名前も Tor 内で解決)、IP アドレス、家庭内ネットワークの名前 (ドットのない NAS・PC 名、*.local、Tailscale の *.ts.net などは端末の DNS)", + "또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "または、もう一方の端末のカメラで QR コードを読み取ってください (コード入り)", "라오어": "ラオ語", "라이선스 원문 보기": "ライセンス原文を見る", "러시아어": "ロシア語", @@ -1312,6 +1332,7 @@ "빈 폴더 그대로": "空フォルダーはそのまま", "빈 폴더 지움 · 원본 폴더는 남김": "空フォルダーを削除 · 元フォルダーは残す", "빈 폴더 지움 · 원본 폴더도 지움": "空フォルダーを削除 · 元フォルダーも削除", + "빈 폴더입니다": "空のフォルダーです", "빠르게": "速く", "빠른 디코더 (TAESD)": "高速デコーダー (TAESD)", "빠른 필터": "クイックフィルター", @@ -1513,6 +1534,7 @@ "아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "まだありません。動画のダウンロードが終わると自動で入ります(設定で変更)。", "아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "まだありません (rsync でコピーするとき · [ダウンロード] で入手)", "아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "まだ保存できませんでした。しばらくしてからもう一度お試しください", + "아직 접속한 기기가 없습니다": "まだ接続した端末はありません", "아직 지원하지 않는 플랫폼입니다: {0}": "まだ対応していないプラットフォームです: {0}", "아직 허용되지 않았습니다": "まだ許可されていません", "안내 닫기": "案内を閉じる", @@ -1579,6 +1601,7 @@ "업데이트로 바뀐 기본값 알림": "アップデートで変わった既定値の通知", "없는 경로": "存在しないパス", "없는 경로: {0}": "存在しないパス: {0}", + "없는 항목입니다": "存在しない項目です", "없음": "なし", "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "なし: 詳細の[移動]を初めて押したときにフォルダーを選んで作成します", "엔진": "エンジン", @@ -1780,6 +1803,7 @@ "이 기기에서는 적게 · 보통 · 많이 가 모두 같습니다: {0} (메모리가 작아 가장 작은 타일)": "この機器では少なめ · 普通 · 多めはすべて同じです: {0} (メモリが少ないため最小のタイル)", "이 기기용 파일 없음": "このデバイス用のファイルなし", "이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다": "この端末の CPU はこの AI エンジンに対応していません", + "이 기기의 Wi-Fi 주소를 찾지 못했습니다. Wi-Fi 에 연결돼 있는지 확인하세요.": "この端末の Wi-Fi アドレスが見つかりません。Wi-Fi に接続しているか確認してください。", "이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "この端末の安全な保存領域を使えないため、パスワードを保存できませんでした", "이 기기의 웹 뷰로는 Tor 화면을 열 수 없습니다": "この端末の Web ビューでは Tor 画面を開けません", "이 기기의 폴더": "この端末のフォルダー", @@ -1905,7 +1929,10 @@ "일반 오류": "一般エラー", "일본어": "日本語", "일시정지": "一時停止", + "읽기 전용 · 보기와 받기만 됩니다": "読み取り専用 · 表示とダウンロードのみ", + "읽기 전용 공유입니다 (보기 · 받기만)": "読み取り専用の共有です (表示 · ダウンロードのみ)", "읽기 전용 매체입니다": "読み取り専用メディアです", + "읽기 전용입니다: 보기와 받기만 되고, 올리기 · 지우기 · 이름 바꾸기는 되지 않습니다. 이 폴더 밖과 링크는 열리지 않습니다.": "読み取り専用です: 表示とダウンロードのみで、アップロード · 削除 · 名前の変更はできません。このフォルダーの外とリンクは開けません。", "읽는 중 · 폴더 {0}개 · {1}": "読み込み中 · フォルダー {0} 件 · {1}", "읽을 수 없습니다": "読み込めません", "읽을 수 없습니다: {0}": "読み込めません: {0}", @@ -1914,6 +1941,7 @@ "읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "読めなかった元のファイルは削除せず、ここに保管しました:", "읽지 못한 폴더 {0}개 (권한 · 연결 문제) - 그 안은 찾지 못했습니다": "読めなかったフォルダー {0} 個 (権限・接続の問題) - その中は探せませんでした", "읽지 못한 폴더 {0}개 (크기가 실제보다 작을 수 있음)": "読めなかったフォルダー {0} 件 (サイズが実際より小さいことがあります)", + "읽지 못했습니다": "読み込めませんでした", "임시 파일 · 남은 조각 정리": "一時ファイル・残った断片を整理", "자동": "自動", "자동 (가로로 긴 그림만)": "自動 (横長の画像のみ)", @@ -2033,6 +2061,8 @@ "저장했습니다": "保存しました", "저장했습니다: {0}": "保存しました: {0}", "저장했습니다: {0} (MKV 에 반영됨)": "保存しました: {0}(MKV に反映済み)", + "저절로 끄기": "自動でオフにする", + "저절로 끄지 않습니다 - 다 쓰면 [끄기] 를 누르세요": "自動ではオフになりません - 終わったら [オフ] を押してください", "적게 (느림)": "少なめ (遅い)", "적게 (느림): {0}": "少なめ (遅い): {0}", "적용": "適用", @@ -2045,6 +2075,9 @@ "전체 화면 (F)": "全画面(F)", "절대 경로": "絶対パス", "점검": "点検", + "접속 QR": "接続用 QR コード", + "접속 기록 ({0}) - 이 화면에만 보이고 저장하지 않습니다": "アクセス記録 ({0}) - この画面にのみ表示され、保存されません", + "접속 코드": "接続コード", "정렬 기준": "並べ替え", "정렬을 폴더마다 기억": "フォルダーごとに並べ替えを記憶", "정리": "整理", @@ -2065,6 +2098,7 @@ "정하지 않음. 마스터를 잊으면 저장된 비밀번호를 지우고 마스터를 초기화하는 것만 할 수 있습니다": "未設定。マスターを忘れたら、保存したパスワードを削除してマスターを初期化することしかできません", "정하지 않음. 정하면 저장된 비밀번호 (WebDAV · OpenSubtitles · API 키) 와 설정을 쓰기 전에 묻습니다": "未設定。設定すると、保存したパスワード (WebDAV・OpenSubtitles・API キー) と設定を使う前に尋ねます", "정하지 않음: MKV 목록에서 [이동] 을 처음 누를 때 고릅니다": "未設定: MKV リストで[移動]を初めて押したときに選びます", + "정한 시간이 지나 저절로 껐습니다": "設定した時間が過ぎたので自動でオフにしました", "정해 둠. 마스터 입력 창에 넣으면 초기화 창이 열립니다 (마스터 · 저장된 비밀번호 · 앱 설정 전체 중 고름)": "設定済み。マスター入力欄に入れると初期化ウィンドウが開きます (マスター・保存したパスワード・アプリの設定すべてから選択)", "정해 둠. 저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다 (앱을 끌 때까지 한 번)": "設定済み。保存したパスワード・設定・パスワードが必要な機能を使うときに尋ねます (アプリを終了するまで 1 回)", "제목": "タイトル", @@ -2090,6 +2124,7 @@ "주말 종일": "週末 終日", "주소": "アドレス", "주소 (http · https · 마그넷) 를 찾지 못했거나 이미 받는 중입니다.": "アドレス (http・https・マグネット) が見つからないか、すでにダウンロード中です。", + "주소 복사": "アドレスをコピー", "주소 이름 (예: youtube.com) 을 찾을 때 물어볼 서버. 직접 지정하면 기기의 DNS 대신 이 서버만 씁니다. 서버에 닿지 않으면 기기 DNS 로 몰래 바꾸지 않고 알립니다.": "ホスト名 (例: youtube.com) を解決するときに問い合わせるサーバー。手動指定すると端末の DNS の代わりにこのサーバーだけを使います。サーバーに届かない場合は、黙って端末の DNS に切り替えずにお知らせします。", "주소 이름이 너무 깁니다: {0}": "ホスト名が長すぎます: {0}", "주소 입력 또는 검색": "アドレスを入力または検索", @@ -2220,6 +2255,7 @@ "처음 쓸 때 내려받기 (기본)": "初めて使うときにダウンロード (既定)", "처음 아이콘 (파랑)": "最初のアイコン(青)", "처음 찾을 때 끝까지 찾기 전의 결과입니다. 더 찾으려면 다시 찾으세요.": "最初の検索が終わる前の結果です。さらに探すには検索し直してください。", + "처음 켤 때 Windows 방화벽 창이 뜨면 \"개인 네트워크\" 에 [허용] 을 누르세요.": "初めてオンにしたときに Windows ファイアウォールの画面が出たら、「プライベート ネットワーク」で [許可] を押してください。", "처음대로": "既定に戻す", "처음부터 정하려면 AI 자막 창의 \"원어 (영상 속 언어)\" 에서 고르세요.": "最初から決めるには、AI 字幕ウィンドウの「原語(動画内の言語)」で選んでください。", "처음으로: {0}": "最初へ: {0}", @@ -2272,7 +2308,12 @@ "켜면 열 머리 · 정렬 기준에서 바꾼 정렬이 그 폴더에만 쓰입니다. 끄면 모든 폴더가 같은 정렬입니다": "オンにすると、列見出し・並べ替えで変えた並べ替えはそのフォルダーだけに使われます。オフではすべてのフォルダーが同じ並べ替えです", "켜면 휴지통으로 보내 되살릴 수 있습니다 (Shift+Delete 는 영구 삭제). 끄면 늘 영구 삭제로 묻습니다. 네트워크 드라이브 · USB 메모리는 휴지통이 없어 늘 영구 삭제입니다": "オンにするとごみ箱に移動し、元に戻せます (Shift+Delete は完全に削除)。オフにすると常に完全に削除として確認します。ネットワークドライブ・USB メモリにはごみ箱がないため常に完全に削除です", "켜져 있는 창에서 (기본)": "開いているウィンドウで(既定)", + "켜지 못했습니다: {0}": "オンにできませんでした: {0}", + "켤 때마다 6자리 코드를 새로 만듭니다. 주소와 코드를 아는 같은 Wi-Fi 의 기기만 이 폴더를 볼 수 있습니다.": "オンにするたびに新しい 6 桁のコードを作ります。アドレスとコードを知っている同じ Wi-Fi の端末だけがこのフォルダーを見られます。", "코덱": "コーデック", + "코드가 맞지 않습니다": "コードが違います", + "코드를 너무 많이 틀려 공유를 껐습니다 (다른 기기가 코드를 맞히려 했을 수 있습니다)": "コードの誤りが多すぎたため共有をオフにしました (別の端末がコードを当てようとした可能性があります)", + "코드를 여러 번 틀려 잠시 막았습니다. 1분 뒤에 다시 하세요.": "コードを何度も間違えたため一時的にブロックしました。1 分後にもう一度お試しください。", "쿠키 · 방문 기록 지우기": "Cookie・閲覧履歴を消去", "쿠키 · 방문 기록을 지웠습니다.": "Cookie・閲覧履歴を消去しました。", "쿠키 파일 지우기": "Cookie ファイルを削除", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 0d736d4..c858d0c 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -63,6 +63,7 @@ "(현재 방식은 옵션 없음)": "(当前方式没有选项)", "* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)": "* 表示任意字符,? 表示一个字符。多个用 ; 分隔(例:*.srt; *.ass)", "+ 여기에 새 줄": "+ 在此新增一行", + "+30분": "+30分钟", "- 감지한 언어": "- 检测到的语言", "-1 은 아무렇게나": "-1 为随机", ".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)", @@ -196,6 +197,7 @@ "IPv4 · IPv6 주소를 넣으세요 (예: 8.8.4.4)": "请输入 IPv4 · IPv6 地址 (例: 8.8.4.4)", "IPv4 · IPv6 주소를 넣으세요 (예: 8.8.8.8)": "请输入 IPv4 · IPv6 地址 (例: 8.8.8.8)", "JJ_MKVMaker - 다운로드 {0}개": "JJ_MKVMaker - 下载 {0} 个", + "JJ_MKVMaker Wi-Fi 공유": "JJ_MKVMaker Wi-Fi 共享", "JJ_MKVMaker 로 자막 만들기": "用 JJ_MKVMaker 生成字幕", "JJ_MKVMaker 로 재생": "用 JJ_MKVMaker 播放", "JJ_MKVMaker 작업 중": "JJ_MKVMaker 正在处理", @@ -342,6 +344,14 @@ "Whisper base (빠름)": "Whisper base(快速)", "Whisper small (정확)": "Whisper small(准确)", "Whisper 모델 (OpenAI)": "Whisper 模型(OpenAI)", + "Wi-Fi 공유": "Wi-Fi 共享", + "Wi-Fi 공유 중 · 코드 {0} · {1}": "Wi-Fi 共享中 · 代码 {0} · {1}", + "Wi-Fi 공유 중 · 코드 {0} · {1}분 남음 · {2}": "Wi-Fi 共享中 · 代码 {0} · 剩余 {1} 分钟 · {2}", + "Wi-Fi 공유 켜짐": "Wi-Fi 共享已开启", + "Wi-Fi 공유는 이 기기의 폴더만 할 수 있습니다": "只能通过 Wi-Fi 共享本设备上的文件夹", + "Wi-Fi 공유를 껐습니다": "已关闭 Wi-Fi 共享", + "Wi-Fi 공유를 켰습니다": "已开启 Wi-Fi 共享", + "Wi-Fi 로 공유": "通过 Wi-Fi 共享", "Windows 가 쓰는 이름 ({0})": "Windows 保留名称 ({0})", "Windows 기본 브라우저": "Windows 默认浏览器", "Windows 기본 프로그램": "Windows 默认程序", @@ -532,6 +542,8 @@ "{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "提高到 {0} 倍并另存为新文件。原文件和 ZIP 保持不变。关闭画面后仍会继续,任务通知中会显示剩余页数和预计时间。", "{0}분": "{0} 分钟", "{0}분 {1}초": "{0} 分 {1} 秒", + "{0}분 뒤": "{0} 分钟后", + "{0}분 뒤에 저절로 꺼집니다": "{0} 分钟后自动关闭", "{0}시간 {1}분": "{0} 小时 {1} 分", "{0}일 안": "{0} 天内", "{0}일 지남": "超过 {0} 天", @@ -585,6 +597,7 @@ "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "仅当检测到的语言与要生成的语言不同时下载 {0}", "강하게 (0.4)": "强 (0.4)", "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "越强优化越多,但可能与原图不同", + "같은 네트워크에서만 열 수 있습니다": "只能在同一网络中打开", "같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} 个覆盖了同名文件的文件不会撤销。", "같은 이름이 이미 있습니다": "已存在同名项目", @@ -659,7 +672,11 @@ "공용 폴더에 받은 파일 (동영상 · 그림 등)": "下载到公共文件夹的文件 (视频、图片等)", "공유": "分享", "공유 (다른 앱으로 보내기)": "分享 (发送到其他应用)", + "공유 중": "共享中", "공유 창을 열 수 없습니다.": "无法打开分享窗口。", + "공유한 기기의 화면에 보이는 6자리 코드를 넣으세요.": "请输入共享设备屏幕上显示的 6 位代码。", + "공유한 폴더 밖이거나 링크라 열 수 없습니다": "无法打开:位于共享文件夹之外或是链接", + "공유할 폴더": "要共享的文件夹", "관리 메뉴": "管理菜单", "관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)": "应用正以管理员权限运行,无法使用 Chrome 引擎(安全原因)", "관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.": "仅在以管理员权限运行时使用。关闭时(默认)届时不启动 Chrome 引擎,并建议使用 Edge。", @@ -739,6 +756,7 @@ "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "关闭后不运行页面脚本 (广告 · 跟踪会减少,但 YouTube 等由脚本构建的网站将无法显示)。使用 Chrome 引擎时,页面翻译 · 视频检测也会停止。", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "关闭后不运行页面脚本 (广告 · 跟踪会减少,但 YouTube 等由脚本构建的网站将无法显示)。页面翻译 · 视频检测也会停止。", "끄면 환경 설정 · 다운로드 목록 · 다른 화면을 보는 동안에도 웹 페이지의 소리가 계속 들립니다": "关闭后,在查看设置、下载列表或其他界面时网页声音会继续播放", + "끄지 않음 (직접 끔)": "不自动关闭 (手动关闭)", "끌어 놓기 기본": "拖放默认操作", "끌어다 놓은 항목에 동영상이 없습니다.": "拖入的项目中没有视频。", "끌어서 다른 창으로": "拖到另一个窗格", @@ -822,6 +840,7 @@ "다 받은 {0} 은 이미 MKV 목록에 있어 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)": "下载完成的 {0} 已在 MKV 列表中,已从下载列表移除(文件保留)", "다 받은 {0} 을 MKV 목록에 넣고 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)": "已将下载完成的 {0} 加入 MKV 列表并从下载列表移除(文件保留)", "다른 곳에서 지웠거나 옮겼을 수 있습니다. 위 폴더로 가 보세요.": "可能已在别处被删除或移动。请返回上级文件夹。", + "다른 기기의 브라우저에서 이 주소를 여세요": "在另一台设备的浏览器中打开此地址", "다른 버전 ({0}) 을 쓰다가 v{1} 로 돌아왔습니다. 그 버전은 이 버전의 설정 일부를 모를 수 있습니다.\nv{1} 를 마지막으로 쓸 때 보관해 둔 설정 (동영상 목록 · 즐겨찾기 포함) 으로 되돌릴까요?": "使用其他版本 ({0}) 后回到了 v{1}。该版本可能不认识此版本的部分设置。\n要恢复最后一次使用 v{1} 时保存的设置 (包括视频列表 · 书签) 吗?", "다른 버전 고르기": "选择其他版本", "다른 브라우저의 즐겨찾기 가져오기": "导入其他浏览器的书签", @@ -999,6 +1018,7 @@ "등록 중 일부가 실패했습니다.": "部分注册失败。", "등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "已注册: “用 JJ_MKVMaker 播放” · “用 JJ_MKVMaker 生成字幕”(Windows 11: 显示更多选项)", "따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "不适用: Tor 画面 (名称也在 Tor 内解析)、IP 地址、家庭网络内的名称 (不带点的 NAS · PC 名称、*.local、Tailscale 的 *.ts.net 等使用设备 DNS)", + "또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "或用另一台设备的相机扫描二维码 (已包含代码)", "라오어": "老挝语", "라이선스 원문 보기": "查看许可证原文", "러시아어": "俄语", @@ -1312,6 +1332,7 @@ "빈 폴더 그대로": "保留空文件夹", "빈 폴더 지움 · 원본 폴더는 남김": "删除空文件夹 · 保留源文件夹", "빈 폴더 지움 · 원본 폴더도 지움": "删除空文件夹 · 源文件夹也删除", + "빈 폴더입니다": "文件夹为空", "빠르게": "快速", "빠른 디코더 (TAESD)": "快速解码器 (TAESD)", "빠른 필터": "快速筛选", @@ -1513,6 +1534,7 @@ "아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "暂无。视频下载完成后会自动加入(可在设置中更改)。", "아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "尚未安装 (用 rsync 复制时 · 或按 [下载] 获取)", "아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "仍未能保存。请稍后再试", + "아직 접속한 기기가 없습니다": "尚无设备连接", "아직 지원하지 않는 플랫폼입니다: {0}": "尚不支持此平台: {0}", "아직 허용되지 않았습니다": "尚未允许", "안내 닫기": "关闭提示", @@ -1579,6 +1601,7 @@ "업데이트로 바뀐 기본값 알림": "更新所更改默认值的通知", "없는 경로": "路径不存在", "없는 경로: {0}": "路径不存在: {0}", + "없는 항목입니다": "项目不存在", "없음": "无", "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "无: 首次在详细信息中点击[移动]时选择文件夹并创建", "엔진": "引擎", @@ -1780,6 +1803,7 @@ "이 기기에서는 적게 · 보통 · 많이 가 모두 같습니다: {0} (메모리가 작아 가장 작은 타일)": "在此设备上,少 · 普通 · 多 都相同:{0}(内存较小,使用最小的分块)", "이 기기용 파일 없음": "没有此设备的文件", "이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다": "此设备的 CPU 不支持该 AI 引擎", + "이 기기의 Wi-Fi 주소를 찾지 못했습니다. Wi-Fi 에 연결돼 있는지 확인하세요.": "找不到本设备的 Wi-Fi 地址。请检查是否已连接 Wi-Fi。", "이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "本设备的安全存储不可用,无法保存密码", "이 기기의 웹 뷰로는 Tor 화면을 열 수 없습니다": "此设备的网页视图无法打开 Tor 画面", "이 기기의 폴더": "本设备上的文件夹", @@ -1905,7 +1929,10 @@ "일반 오류": "一般错误", "일본어": "日语", "일시정지": "暂停", + "읽기 전용 · 보기와 받기만 됩니다": "只读 · 仅可查看和下载", + "읽기 전용 공유입니다 (보기 · 받기만)": "这是只读共享 (仅可查看和下载)", "읽기 전용 매체입니다": "只读介质", + "읽기 전용입니다: 보기와 받기만 되고, 올리기 · 지우기 · 이름 바꾸기는 되지 않습니다. 이 폴더 밖과 링크는 열리지 않습니다.": "只读:只能查看和下载,不能上传、删除或重命名。无法打开此文件夹之外的内容和链接。", "읽는 중 · 폴더 {0}개 · {1}": "正在读取 · {0} 个文件夹 · {1}", "읽을 수 없습니다": "无法读取", "읽을 수 없습니다: {0}": "无法读取: {0}", @@ -1914,6 +1941,7 @@ "읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "无法读取的原文件没有删除,保存在这里:", "읽지 못한 폴더 {0}개 (권한 · 연결 문제) - 그 안은 찾지 못했습니다": "{0} 个无法读取的文件夹 (权限 · 连接问题) - 未在其中查找", "읽지 못한 폴더 {0}개 (크기가 실제보다 작을 수 있음)": "{0} 个文件夹无法读取 (大小可能小于实际)", + "읽지 못했습니다": "无法读取", "임시 파일 · 남은 조각 정리": "清理临时文件 · 残留碎片", "자동": "自动", "자동 (가로로 긴 그림만)": "自动 (仅横向图片)", @@ -2033,6 +2061,8 @@ "저장했습니다": "已保存", "저장했습니다: {0}": "已保存: {0}", "저장했습니다: {0} (MKV 에 반영됨)": "已保存: {0}(已应用到 MKV)", + "저절로 끄기": "自动关闭", + "저절로 끄지 않습니다 - 다 쓰면 [끄기] 를 누르세요": "不会自动关闭 - 用完后请按 [关闭]", "적게 (느림)": "少(较慢)", "적게 (느림): {0}": "少(较慢):{0}", "적용": "应用", @@ -2045,6 +2075,9 @@ "전체 화면 (F)": "全屏(F)", "절대 경로": "绝对路径", "점검": "检查", + "접속 QR": "连接二维码", + "접속 기록 ({0}) - 이 화면에만 보이고 저장하지 않습니다": "访问记录 ({0}) - 仅在此屏幕显示,不会保存", + "접속 코드": "访问代码", "정렬 기준": "排序方式", "정렬을 폴더마다 기억": "按文件夹记住排序", "정리": "清理", @@ -2065,6 +2098,7 @@ "정하지 않음. 마스터를 잊으면 저장된 비밀번호를 지우고 마스터를 초기화하는 것만 할 수 있습니다": "未设置。忘记主密码时,只能删除已保存的密码并重置主密码", "정하지 않음. 정하면 저장된 비밀번호 (WebDAV · OpenSubtitles · API 키) 와 설정을 쓰기 전에 묻습니다": "未设置。设置后,在使用已保存的密码 (WebDAV · OpenSubtitles · API 密钥) 和设置前询问", "정하지 않음: MKV 목록에서 [이동] 을 처음 누를 때 고릅니다": "未设置: 首次在 MKV 列表中点击[移动]时选择", + "정한 시간이 지나 저절로 껐습니다": "已到设定时间,自动关闭", "정해 둠. 마스터 입력 창에 넣으면 초기화 창이 열립니다 (마스터 · 저장된 비밀번호 · 앱 설정 전체 중 고름)": "已设置。在主密码输入框中输入后会打开重置窗口 (从主密码 · 已保存的密码 · 全部应用设置中选择)", "정해 둠. 저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다 (앱을 끌 때까지 한 번)": "已设置。使用已保存的密码 · 设置 · 需要密码的功能时询问 (在关闭应用前只问一次)", "제목": "标题", @@ -2090,6 +2124,7 @@ "주말 종일": "周末 全天", "주소": "地址", "주소 (http · https · 마그넷) 를 찾지 못했거나 이미 받는 중입니다.": "未找到地址 (http · https · 磁力),或已在下载。", + "주소 복사": "复制地址", "주소 이름 (예: youtube.com) 을 찾을 때 물어볼 서버. 직접 지정하면 기기의 DNS 대신 이 서버만 씁니다. 서버에 닿지 않으면 기기 DNS 로 몰래 바꾸지 않고 알립니다.": "解析主机名 (例: youtube.com) 时询问的服务器。手动指定时只使用此服务器而不使用设备 DNS。无法连接服务器时会提示,而不会悄悄切换到设备 DNS。", "주소 이름이 너무 깁니다: {0}": "主机名过长: {0}", "주소 입력 또는 검색": "输入地址或搜索", @@ -2220,6 +2255,7 @@ "처음 쓸 때 내려받기 (기본)": "首次使用时下载 (默认)", "처음 아이콘 (파랑)": "最初的图标(蓝色)", "처음 찾을 때 끝까지 찾기 전의 결과입니다. 더 찾으려면 다시 찾으세요.": "这些是首次搜索完成前的结果。如需查找更多,请重新搜索。", + "처음 켤 때 Windows 방화벽 창이 뜨면 \"개인 네트워크\" 에 [허용] 을 누르세요.": "首次开启时如出现 Windows 防火墙窗口,请对\"专用网络\"按 [允许]。", "처음대로": "恢复默认", "처음부터 정하려면 AI 자막 창의 \"원어 (영상 속 언어)\" 에서 고르세요.": "如需一开始就指定,请在 AI 字幕窗口的“原语言(视频中的语言)”中选择。", "처음으로: {0}": "回到第一个: {0}", @@ -2272,7 +2308,12 @@ "켜면 열 머리 · 정렬 기준에서 바꾼 정렬이 그 폴더에만 쓰입니다. 끄면 모든 폴더가 같은 정렬입니다": "开启后,通过列标题或排序方式更改的排序只用于该文件夹。关闭时所有文件夹使用同一排序", "켜면 휴지통으로 보내 되살릴 수 있습니다 (Shift+Delete 는 영구 삭제). 끄면 늘 영구 삭제로 묻습니다. 네트워크 드라이브 · USB 메모리는 휴지통이 없어 늘 영구 삭제입니다": "开启后移到回收站,可以恢复 (Shift+Delete 为永久删除)。关闭后始终按永久删除询问。网络驱动器 · U 盘没有回收站,始终永久删除", "켜져 있는 창에서 (기본)": "在已打开的窗口中(默认)", + "켜지 못했습니다: {0}": "无法开启:{0}", + "켤 때마다 6자리 코드를 새로 만듭니다. 주소와 코드를 아는 같은 Wi-Fi 의 기기만 이 폴더를 볼 수 있습니다.": "每次开启都会生成新的 6 位代码。只有知道地址和代码且在同一 Wi-Fi 下的设备才能查看此文件夹。", "코덱": "编解码器", + "코드가 맞지 않습니다": "代码不正确", + "코드를 너무 많이 틀려 공유를 껐습니다 (다른 기기가 코드를 맞히려 했을 수 있습니다)": "代码错误次数过多,已关闭共享 (可能有其他设备在尝试猜测代码)", + "코드를 여러 번 틀려 잠시 막았습니다. 1분 뒤에 다시 하세요.": "代码多次错误,已暂时阻止。请 1 分钟后再试。", "쿠키 · 방문 기록 지우기": "清除 Cookie · 浏览记录", "쿠키 · 방문 기록을 지웠습니다.": "已清除 Cookie · 浏览记录。", "쿠키 파일 지우기": "删除 Cookie 文件", diff --git a/assets/licenses/THIRD_PARTY_NOTICES.txt b/assets/licenses/THIRD_PARTY_NOTICES.txt index 7c93a8a..fbcd997 100644 --- a/assets/licenses/THIRD_PARTY_NOTICES.txt +++ b/assets/licenses/THIRD_PARTY_NOTICES.txt @@ -50,6 +50,8 @@ WHISPER_CPP_LICENSE.txt · ONNXRUNTIME_LICENSES.txt 는 실행 파일 옆에도 - cp949_codec BSD https://pub.dev/packages/cp949_codec - archive 4.3.0 (ZIP 풀기 · 묶기) MIT https://pub.dev/packages/archive Copyright (c) 2013-2021 Brendan Duncan. +- qr 3.0.2 (Wi-Fi 공유 접속 QR) BSD-3-Clause https://pub.dev/packages/qr + Copyright 2014, the Dart QR project authors. - Microsoft Visual C++ 런타임 (msvcp140.dll, vcruntime140*.dll) Microsoft 재배포 가능 파일 (Visual Studio 라이선스의 Distributable Code) - 그 밖의 Dart 패키지 라이선스: 프로그램의 "환경 설정 > 라이선스" 화면 diff --git a/assets/licenses/THIRD_PARTY_NOTICES_ANDROID.txt b/assets/licenses/THIRD_PARTY_NOTICES_ANDROID.txt index d57fc66..4adf4c3 100644 --- a/assets/licenses/THIRD_PARTY_NOTICES_ANDROID.txt +++ b/assets/licenses/THIRD_PARTY_NOTICES_ANDROID.txt @@ -31,6 +31,8 @@ JJ_MKVMaker (Android) - 포함된 오픈 소스 구성 요소 및 라이선스 - file_picker · android_file_picker MIT https://pub.dev/packages/file_picker - archive 4.3.0 (ZIP 풀기 · 묶기) MIT https://pub.dev/packages/archive Copyright (c) 2013-2021 Brendan Duncan. +- qr 3.0.2 (Wi-Fi 공유 접속 QR) BSD-3-Clause https://pub.dev/packages/qr + Copyright 2014, the Dart QR project authors. - charset (Apache-2.0) · cp949_codec (BSD) · 그 밖의 Dart 패키지: [패키지 라이선스] [앱에 들어 있는 별도 실행 파일 · 라이브러리] diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 84a479a..f2398b9 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -429,6 +429,12 @@ class AppSettings { /// 85: 탐색기의 복사 · 이동 진행 패널을 숨김 (아래 작은 표시만). 처음 값: 보임 bool explorerProgressHidden = false; + /// 89: Wi-Fi 공유 - 저절로 끄는 시간 (분, 0 = 끄지 않음). 처음 값 30분 (관리자 지시 10/17) + int wifiShareMinutes = 30; + + /// 89: Wi-Fi 공유로 마지막에 고른 폴더 (다시 켤 때 처음 고른 것) + String wifiShareFolder = ''; + /// 86: ZIP 으로 묶기의 압축 정도 (1 빠르게 · 6 보통 (처음 값) · 9 작게) - 마지막에 고른 것을 기억 int zipLevel = 6; bool explorerShowHidden = false; @@ -759,6 +765,8 @@ class AppSettings { 'explorerDropAction': explorerDropAction, 'explorerProgressHidden': explorerProgressHidden, 'zipLevel': zipLevel, + 'wifiShareMinutes': wifiShareMinutes, + 'wifiShareFolder': wifiShareFolder, 'explorerSortPerFolder': explorerSortPerFolder, 'explorerFolderSorts': explorerFolderSorts, 'explorerShowHidden': explorerShowHidden, @@ -980,6 +988,8 @@ class AppSettings { ..explorerSortPerFolder = j['explorerSortPerFolder'] == true ..explorerProgressHidden = j['explorerProgressHidden'] == true ..zipLevel = const [1, 6, 9].contains(j['zipLevel']) ? j['zipLevel'] as int : 6 + ..wifiShareMinutes = const [0, 10, 30, 60, 120].contains(j['wifiShareMinutes']) ? j['wifiShareMinutes'] as int : 30 + ..wifiShareFolder = j['wifiShareFolder'] is String ? j['wifiShareFolder'] as String : '' ..explorerDropAction = const ['ask', 'copy', 'move'].contains(j['explorerDropAction']) ? j['explorerDropAction'] as String : 'ask' ..explorerFolderSorts = { if (j['explorerFolderSorts'] is Map) diff --git a/lib/core/wifi_share.dart b/lib/core/wifi_share.dart new file mode 100644 index 0000000..c42981a --- /dev/null +++ b/lib/core/wifi_share.dart @@ -0,0 +1,462 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; + +import '../l10n/tr.dart'; +import 'file_ops.dart' show formatSize, isAppTempName, isPartialFile; + +/// 89: Wi-Fi 공유 - 앱 안 작은 HTTP 서버로 고른 폴더 하나를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용). +/// 사용자 결정 (10/9): 기본 꺼짐 · 읽기 전용 · 접속 비밀번호. 관리자 지시 (10/17): +/// - 켤 때마다 6자리 코드를 새로 만든다 (주소 · QR · 코드를 화면에) +/// - 고른 폴더 밖은 막는다 (../ · 절대 경로 · 링크 · junction 을 따라가지 않음) +/// - 정한 시간이 지나면 저절로 꺼진다 (처음 30분). 앱을 끄면 함께 꺼진다 +/// - 접속 기록은 화면에만 (작업 기록 파일에 남기지 않음), 코드는 어디에도 기록하지 않는다 +/// 보안 (관리자 판단 · 테스터 15): +/// - GET · HEAD 만 (그 밖은 405). 올리기 · 지우기 · 이름 바꾸기 없음 +/// - 코드를 틀리면 그 기기는 5번에 1분 막고, 모두 합쳐 30번 틀리면 공유를 끈다 (6자리 무작위 대입 막기) +/// - 사설망 (10/8 · 172.16/12 · 192.168/16 · 169.254/16 · 100.64/10 · 이 기기) 에서 온 요청만 받는다 +/// - 공유한 파일은 격리해 보낸다 (CSP sandbox · nosniff, HTML · SVG · JS 는 내려받기로) - 공유 폴더 안의 웹 페이지가 +/// 접속한 브라우저에서 스크립트를 돌리지 못하게 +class WifiShare extends ChangeNotifier { + /// 지금 켜진 공유 (한 번에 하나) + static WifiShare? get instance => current.value; + + /// 켜고 끌 때 알림 (탐색기 위 줄 · Android 알림이 듣는다) + static final current = ValueNotifier(null); + + /// 처음 해 볼 포트 (쓰는 중이면 빈 포트) + static const defaultPort = 8765; + + /// 틀린 코드: 한 기기에서 [perClientFails] 번이면 [lockout] 동안 막고, 모두 [totalFails] 번이면 끈다 + static const perClientFails = 5; + static const lockout = Duration(minutes: 1); + static const totalFails = 30; + + /// 시험: 화면 시험에서 이 기기 (127.0.0.1) 에만 연다 (시험 PC 의 방화벽 창 · 바깥 노출 없이) + @visibleForTesting + static bool debugLoopbackOnly = false; + + /// 화면에 보이는 접속 기록 개수 + static const maxLog = 200; + + final String root; + final String _rootReal; + final String code; + final HttpServer _server; + final Random _rng; + final DateTime started; + DateTime? endsAt; + Timer? _timer; + bool _stopped = false; + + /// 꺼진 까닭 (사용자가 끔이면 null) + String? stopReason; + + /// 접속 기록 (새것이 앞) + final log = []; + final _sessions = {}; + final _fails = {}; + int _totalFails = 0; + + /// 이 기기의 주소들 (사설망 먼저). 화면 · QR 에 쓴다 + List addresses = const []; + + WifiShare._(this.root, this._rootReal, this.code, this._server, this._rng) : started = DateTime.now(); + + int get port => _server.port; + bool get running => !_stopped; + + /// 접속 주소 (코드를 넣은 것 - QR 용) + String urlWithCode(String host) => 'http://$host:$port/?code=$code'; + + /// 공유를 켠다. 이미 켜진 것이 있으면 먼저 끈다. [autoOff] 가 null 이면 저절로 끄지 않는다 + static Future start(String root, {Duration? autoOff, int port = defaultPort, Random? random, bool loopbackOnly = false}) async { + await instance?.stop(); + final dir = Directory(root); + if (!dir.existsSync()) throw FileSystemException(tr('폴더가 없습니다'), root); + final real = dir.resolveSymbolicLinksSync(); + final rng = random ?? Random.secure(); + final code = List.generate(6, (_) => rng.nextInt(10)).join(); + final any = loopbackOnly || debugLoopbackOnly ? InternetAddress.loopbackIPv4 : InternetAddress.anyIPv4; + HttpServer server; + try { + server = await HttpServer.bind(any, port); + } on SocketException { + server = await HttpServer.bind(any, 0); // 쓰는 중이면 빈 포트 + } + final s = WifiShare._(root, real, code, server, rng); + server.autoCompress = false; + server.defaultResponseHeaders.clear(); + server.serverHeader = null; + s.addresses = await localAddresses(); + if (autoOff != null && autoOff > Duration.zero) { + s.endsAt = s.started.add(autoOff); + s._timer = Timer(autoOff, () => s.stop(reason: tr('정한 시간이 지나 저절로 껐습니다'))); + } + server.listen(s._handle, onError: (_) {}); + current.value = s; + return s; + } + + /// 공유를 끈다 (접속 중인 것도 끊는다) + Future stop({String? reason}) async { + if (_stopped) return; + _stopped = true; + stopReason = reason; + _timer?.cancel(); + _sessions.clear(); + if (identical(current.value, this)) current.value = null; + try { + await _server.close(force: true); + } catch (_) {} + notifyListeners(); + } + + /// 저절로 꺼질 시각을 [more] 만큼 늦춘다 + void extend(Duration more) { + if (_stopped || endsAt == null) return; + endsAt = endsAt!.add(more); + _timer?.cancel(); + _timer = Timer(endsAt!.difference(DateTime.now()), () => stop(reason: tr('정한 시간이 지나 저절로 껐습니다'))); + notifyListeners(); + } + + /// 이 기기의 IPv4 주소 (이 기기 자신 제외, 사설망 먼저) + static Future> localAddresses() async { + try { + final list = await NetworkInterface.list(type: InternetAddressType.IPv4, includeLoopback: false); + final all = [for (final i in list) for (final a in i.addresses) a.address]; + int rank(String a) => a.startsWith('192.168.') ? 0 : a.startsWith('10.') ? 1 : a.startsWith('172.') ? 2 : 3; + return (all.where(isLocalNetwork).toSet().toList()..sort((a, b) => rank(a).compareTo(rank(b)))); + } catch (_) { + return const []; + } + } + + /// 사설망 · 링크 로컬 · Tailscale (100.64/10) · 이 기기 주소인지 + static bool isLocalNetwork(String ip) { + final v = ip.split('.').map(int.tryParse).toList(); + if (v.length != 4 || v.any((x) => x == null)) return ip == '::1'; + final a = v[0]!, b = v[1]!; + return a == 10 || + a == 127 || + (a == 172 && b >= 16 && b <= 31) || + (a == 192 && b == 168) || + (a == 169 && b == 254) || + (a == 100 && b >= 64 && b <= 127); + } + + /// [rel] (공유 폴더 기준, '/' 로 나눈 것) 의 실제 경로. 폴더 밖 · 링크 · 쓸 수 없는 이름이면 null + String? resolve(String rel) { + final segs = []; + for (final s in rel.split('/')) { + if (s.isEmpty) continue; + if (s == '.' || s == '..' || s.contains('\\') || s.contains(':') || s.contains('\u0000')) return null; + segs.add(s); + } + var at = _rootReal; + for (final s in segs) { + at = p.join(at, s); + // 링크 · junction 은 (가리키는 곳이 공유 폴더 안이어도) 따라가지 않는다 + if (FileSystemEntity.typeSync(at, followLinks: false) == FileSystemEntityType.link) return null; + } + if (FileSystemEntity.typeSync(at, followLinks: false) == FileSystemEntityType.notFound) return null; + try { + final real = FileSystemEntity.isDirectorySync(at) ? Directory(at).resolveSymbolicLinksSync() : File(at).resolveSymbolicLinksSync(); + if (!p.equals(real, _rootReal) && !p.isWithin(_rootReal, real)) return null; + } catch (_) { + return null; + } + return at; + } + + /// [resolve] 가 null 일 때: 막은 것 (폴더 밖 · 링크 · 쓸 수 없는 이름) 인지, 그냥 없는 것인지 + bool _blocked(String rel) { + var at = _rootReal; + for (final s in rel.split('/')) { + if (s.isEmpty) continue; + if (s == '.' || s == '..' || s.contains('\\') || s.contains(':') || s.contains('\u0000')) return true; + at = p.join(at, s); + final t = FileSystemEntity.typeSync(at, followLinks: false); + if (t == FileSystemEntityType.link) return true; + if (t == FileSystemEntityType.notFound) return false; + } + return true; // 있는데 밖을 가리킴 + } + + Future _handle(HttpRequest req) async { + final res = req.response; + final ip = req.connectionInfo?.remoteAddress.address ?? '?'; + var status = 200; + var path = '/'; + try { + res.headers + ..set('X-Content-Type-Options', 'nosniff') + ..set('Referrer-Policy', 'no-referrer') + ..set('Cache-Control', 'no-store') + ..set('X-Frame-Options', 'DENY'); + if (!isLocalNetwork(ip)) { + status = 403; + return await _text(res, 403, tr('같은 네트워크에서만 열 수 있습니다')); + } + if (req.method != 'GET' && req.method != 'HEAD') { + status = 405; + res.headers.set('Allow', 'GET, HEAD'); + return await _text(res, 405, tr('읽기 전용 공유입니다 (보기 · 받기만)')); + } + path = req.uri.path; + // 코드 확인 (쿠키가 없으면 ?code= 로) + if (!_hasSession(req)) { + final given = req.uri.queryParameters['code']; + final locked = _lockedUntil(ip); + if (locked != null) { + status = 429; + res.headers.set('Retry-After', '${locked.difference(DateTime.now()).inSeconds + 1}'); + return await _page(res, 429, _loginHtml(tr('코드를 여러 번 틀려 잠시 막았습니다. 1분 뒤에 다시 하세요.'))); + } + if (given == null || given.isEmpty) { + status = 401; + return await _page(res, 401, _loginHtml(null)); + } + if (!_sameCode(given)) { + status = 401; + _fail(ip); + return await _page(res, 401, _loginHtml(tr('코드가 맞지 않습니다'))); + } + _fails.remove(ip); + final token = base64Url.encode(List.generate(32, (_) => _rng.nextInt(256))).replaceAll('=', ''); + _sessions.add(token); + res.headers.set('Set-Cookie', 'jjshare=$token; Path=/; HttpOnly; SameSite=Strict'); + // 코드가 주소 막대 · 기록에 남지 않게 코드를 뺀 주소로 다시 + final q = Map.of(req.uri.queryParameters)..remove('code'); + status = 303; + res.statusCode = 303; + res.headers.set('Location', q.isEmpty ? req.uri.path : '${req.uri.path}?${Uri(queryParameters: q).query}'); + await res.close(); + return; + } + final rel = Uri.decodeComponent(path); + final target = resolve(rel); + if (target == null) { + status = _blocked(rel) ? 403 : 404; + return await _text(res, status, status == 403 ? tr('공유한 폴더 밖이거나 링크라 열 수 없습니다') : tr('없는 항목입니다')); + } + if (FileSystemEntity.isDirectorySync(target)) { + return await _page(res, 200, _listHtml(rel, target), head: req.method == 'HEAD'); + } + status = await _file(req, res, target); + } catch (_) { + status = 500; + try { + await _text(res, 500, tr('읽지 못했습니다')); + } catch (_) {} + } finally { + _record(ip, path, status); + } + } + + bool _hasSession(HttpRequest req) { + for (final c in req.cookies) { + if (c.name == 'jjshare' && _sessions.contains(c.value)) return true; + } + return false; + } + + /// 걸리는 시간이 맞는 자리 수에 따라 달라지지 않게 비교 + bool _sameCode(String given) { + if (given.length != code.length) return false; + var d = 0; + for (var i = 0; i < code.length; i++) { + d |= given.codeUnitAt(i) ^ code.codeUnitAt(i); + } + return d == 0; + } + + DateTime? _lockedUntil(String ip) { + final f = _fails[ip]; + if (f == null || f.$1 < perClientFails) return null; + final until = f.$2.add(lockout); + if (DateTime.now().isAfter(until)) { + _fails.remove(ip); + return null; + } + return until; + } + + void _fail(String ip) { + final f = _fails[ip]; + _fails[ip] = ((f?.$1 ?? 0) + 1, DateTime.now()); + _totalFails++; + if (_totalFails >= totalFails) { + unawaited(stop(reason: tr('코드를 너무 많이 틀려 공유를 껐습니다 (다른 기기가 코드를 맞히려 했을 수 있습니다)'))); + } + } + + /// 시험: 틀린 코드 하나를 센다 (막힌 기기에서 온 것처럼 서버를 거치지 않고) + @visibleForTesting + void debugFail(String ip) => _fail(ip); + + void _record(String ip, String path, int status) { + if (_stopped) return; + log.insert(0, WifiAccess(DateTime.now(), ip, path, status)); + if (log.length > maxLog) log.removeRange(maxLog, log.length); + notifyListeners(); + } + + /// 파일 보내기 (Range 한 구간 지원 - 동영상 건너뛰기). 돌려주는 값: 상태 코드 + Future _file(HttpRequest req, HttpResponse res, String path) async { + final f = File(path); + final len = await f.length(); + final name = p.basename(path); + final ext = p.extension(name).toLowerCase().replaceFirst('.', ''); + final active = const {'html', 'htm', 'xhtml', 'svg', 'xml', 'js', 'mjs'}.contains(ext); + final download = active || req.uri.queryParameters['dl'] == '1'; + res.headers + ..set('Content-Type', active ? 'application/octet-stream' : contentTypeOf(ext)) + ..set('Content-Security-Policy', 'sandbox') + ..set('Accept-Ranges', 'bytes') + ..set('Content-Disposition', '${download ? 'attachment' : 'inline'}; filename*=UTF-8\'\'${Uri.encodeComponent(name)}'); + var start = 0, end = len - 1, status = 200; + final range = req.headers.value('range'); + final m = range == null ? null : RegExp(r'^bytes=(\d*)-(\d*)$').firstMatch(range.trim()); + if (m != null && len > 0) { + final a = int.tryParse(m[1]!), b = int.tryParse(m[2]!); + if (a == null && b != null) { + start = max(0, len - b); + } else if (a != null) { + start = a; + if (b != null) end = min(b, len - 1); + } + if (start > end || start >= len) { + res.statusCode = 416; + res.headers.set('Content-Range', 'bytes */$len'); + await res.close(); + return 416; + } + status = 206; + res.headers.set('Content-Range', 'bytes $start-$end/$len'); + } + res.statusCode = status; + res.contentLength = len == 0 ? 0 : end - start + 1; + if (req.method == 'HEAD' || len == 0) { + await res.close(); + return status; + } + await res.addStream(f.openRead(start, end + 1)); + await res.close(); + return status; + } + + static String contentTypeOf(String ext) => switch (ext) { + 'txt' || 'srt' || 'smi' || 'ass' || 'ssa' || 'vtt' || 'log' || 'md' || 'csv' || 'ini' || 'nfo' => 'text/plain; charset=utf-8', + 'json' => 'application/json', + 'pdf' => 'application/pdf', + 'jpg' || 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'bmp' => 'image/bmp', + 'mp4' || 'm4v' => 'video/mp4', + 'webm' => 'video/webm', + 'mkv' => 'video/x-matroska', + 'mov' => 'video/quicktime', + 'mp3' => 'audio/mpeg', + 'm4a' || 'aac' => 'audio/aac', + 'ogg' || 'opus' => 'audio/ogg', + 'wav' => 'audio/wav', + 'flac' => 'audio/flac', + 'zip' => 'application/zip', + _ => 'application/octet-stream', + }; + + Future _text(HttpResponse res, int status, String msg) async { + res.statusCode = status; + res.headers.set('Content-Type', 'text/plain; charset=utf-8'); + res.write(msg); + await res.close(); + } + + Future _page(HttpResponse res, int status, String html, {bool head = false}) async { + res.statusCode = status; + res.headers + ..set('Content-Type', 'text/html; charset=utf-8') + // 앱이 만드는 화면에도 스크립트를 쓰지 않는다 + ..set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; form-action 'self'"); + if (!head) res.write(html); + await res.close(); + } + + static String _esc(String s) => + s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); + + static String _href(String rel) => '/${rel.split('/').where((s) => s.isNotEmpty).map(Uri.encodeComponent).join('/')}'; + + static const _css = 'body{font-family:sans-serif;background:#16171c;color:#e8e8ea;margin:0;padding:16px;max-width:900px}' + 'a{color:#4fd1d9;text-decoration:none}h1{font-size:18px;word-break:break-all}' + 'table{width:100%;border-collapse:collapse}td{padding:8px 4px;border-bottom:1px solid #2a2b33;word-break:break-all}' + 'td.s{color:#9a9aa3;white-space:nowrap;text-align:right;font-size:13px}' + 'input{font-size:24px;letter-spacing:6px;width:9em;padding:8px;margin-right:8px}button{font-size:18px;padding:10px 16px}' + '.e{color:#ff6b6b}.n{color:#9a9aa3;font-size:13px}'; + + String _loginHtml(String? error) => '' + 'JJ_MKVMaker' + '

${_esc(tr('JJ_MKVMaker Wi-Fi 공유'))}

' + '

${_esc(tr('공유한 기기의 화면에 보이는 6자리 코드를 넣으세요.'))}

' + '${error == null ? '' : '

${_esc(error)}

'}' + '
' + '
'; + + String _listHtml(String rel, String dir) { + final entries = []; + try { + for (final e in Directory(dir).listSync(followLinks: false)) { + final n = p.basename(e.path); + if (e is Link || isAppTempName(n) || isPartialFile(n)) continue; // 링크는 보이지도 않게 + entries.add(e); + } + } catch (_) {} + entries.sort((a, b) { + final d = (a is Directory ? 0 : 1) - (b is Directory ? 0 : 1); + return d != 0 ? d : p.basename(a.path).toLowerCase().compareTo(p.basename(b.path).toLowerCase()); + }); + final parts = rel.split('/').where((s) => s.isNotEmpty).toList(); + final title = parts.isEmpty ? p.basename(root) : '${p.basename(root)} / ${parts.join(' / ')}'; + final b = StringBuffer('' + '${_esc(title)}' + '

${_esc(title)}

${_esc(tr('읽기 전용 · 보기와 받기만 됩니다'))}

'); + if (parts.isNotEmpty) { + final up = _href(parts.sublist(0, parts.length - 1).join('/')); + b.write(''); + } + for (final e in entries) { + final n = p.basename(e.path); + final href = _href([...parts, n].join('/')); + if (e is Directory) { + b.write(''); + } else { + int size = 0; + try { + size = (e as File).lengthSync(); + } catch (_) {} + b.write('' + ''); + } + } + if (entries.isEmpty) b.write(''); + b.write('
⬆ ${_esc(tr('상위 폴더'))}
📁 ${_esc(n)}
${_esc(n)}${_esc(formatSize(size))} · ${_esc(tr('받기'))}
${_esc(tr('빈 폴더입니다'))}
'); + return b.toString(); + } +} + +/// 89: 화면에 보이는 접속 한 줄 (파일에는 남기지 않는다) +class WifiAccess { + final DateTime at; + final String ip; + final String path; + final int status; + const WifiAccess(this.at, this.ip, this.path, this.status); +} diff --git a/lib/platform/android/android_keep_alive.dart b/lib/platform/android/android_keep_alive.dart index 3af25c4..e14d2ea 100644 --- a/lib/platform/android/android_keep_alive.dart +++ b/lib/platform/android/android_keep_alive.dart @@ -9,6 +9,7 @@ import '../../app/download_manager.dart'; import '../../app/copy_center.dart'; import '../../app/live_sync.dart'; import '../../services/downloader.dart'; +import '../../core/wifi_share.dart'; import '../../l10n/tr.dart'; /// Android: 다운로드 · MKV 만들기 · AI 자막이 진행 중이면 포그라운드 서비스 (MainActivity.kt · KeepAliveService.kt) @@ -29,6 +30,7 @@ class AndroidKeepAlive { downloads.addListener(_changed); AiJobs.instance.addListener(_changed); // 123: AI 그림도 화면을 닫아도 계속 i18n.addListener(_changed); // 57: 알림 글 · 채널 이름도 화면 언어로 + WifiShare.current.addListener(_changed); // 89: Wi-Fi 공유가 켜져 있는 동안 (화면을 닫아도 서버가 살아 있게) _changed(); } @@ -100,7 +102,8 @@ class AndroidKeepAlive { syncing: live?.anyRunning ?? false, copies: copying, copyProgress: copies?.activeProgress, - stopped: live?.problems.length ?? 0); + stopped: live?.problems.length ?? 0, + wifiShare: WifiShare.instance != null); if (ai.isNotEmpty) { // 진행 알림에 남은 장 수와 예상 시간 (발열 · 배터리 때문에 쉬어 가지는 않는다 - 사용자 결정) text = text == null ? ai : '$ai · $text'; @@ -127,7 +130,7 @@ class AndroidKeepAlive { /// 알림 글과 진행률 (0~100, 모르면 -1). 진행 중인 일이 없으면 글이 null. /// [syncs] 백그라운드로 지켜보는 실시간 동기화 수 ([syncing] 지금 맞추는 중) static (String?, int) status(String? job, bool busy, int downloads, double? downloadProgress, - {int syncs = 0, bool syncing = false, int copies = 0, double? copyProgress, int stopped = 0}) { + {int syncs = 0, bool syncing = false, int copies = 0, double? copyProgress, int stopped = 0, bool wifiShare = false}) { final parts = [ // 68: 원본을 읽지 못해 멈춘 동기화 (화면을 보지 않아도 알 수 있게 맨 앞에) if (stopped > 0) trf('⚠ 동기화 멈춤 {0}개 · 원본 확인 필요', [stopped]), @@ -137,6 +140,8 @@ class AndroidKeepAlive { copyProgress == null ? trf('복사 · 이동 {0}개', [copies]) : trf('복사 · 이동 {0}개 · {1}%', [copies, (copyProgress * 100).round()]), if (downloads > 0) trf('다운로드 {0}개{1}', [downloads, downloadProgress == null ? '' : ' · ${(downloadProgress * 100).round()}%']), if (syncs > 0) syncing ? trf('동기화 {0}개 · 맞추는 중', [syncs]) : trf('동기화 {0}개 지켜보는 중', [syncs]), + // 89: 켜져 있음을 알림줄에도 (코드 · 주소는 알림에 넣지 않는다 - 잠금 화면에 보일 수 있음) + if (wifiShare) tr('Wi-Fi 공유 켜짐'), ]; if (parts.isEmpty) return (null, -1); // 진행률 막대는 다운로드만 있을 때 (작업 진행률은 동영상마다 따로라 하나로 합치기 어렵다). diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index b2ecd21..8cf11c7 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -48,6 +48,8 @@ import 'quick_look.dart'; import 'explorer_drag.dart'; import 'archive_ui.dart'; import 'storage_page.dart'; +import 'wifi_share_page.dart'; +import '../core/wifi_share.dart' show WifiShare; import '../core/archive_ops.dart' show ArchiveKind, archiveKindOf; import 'batch_rename_dialog.dart'; import '../core/folder_size.dart'; @@ -164,7 +166,9 @@ enum ExplorerButton { layout(Icons.view_quilt_outlined, '창 배치'), buttons(Icons.tune, '버튼 구성'), // 87: 지금 폴더 (드라이브) 의 큰 폴더 · 큰 파일 - storage(Icons.pie_chart_outline, '저장 공간 분석'); + storage(Icons.pie_chart_outline, '저장 공간 분석'), + // 89: 지금 폴더를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용) + wifiShare(Icons.wifi_tethering, 'Wi-Fi 공유'); final IconData icon; final String label; @@ -1364,6 +1368,44 @@ class _ExplorerPageState extends State { _reveal(pane, path); } + /// 89: Wi-Fi 공유 화면 (이미 켜져 있으면 그 공유를 보인다). 이 기기의 폴더만 - WebDAV 등 원격은 알림 + Future _wifiShare(String dir) async { + final on = WifiShare.instance; + if (on == null && isDav(dir)) { + _snack(tr('Wi-Fi 공유는 이 기기의 폴더만 할 수 있습니다')); + return; + } + await WifiSharePage.open(context, c, on?.root ?? dir); + } + + /// 89: 탐색기 위의 [Wi-Fi 공유 중] 줄 - 화면을 닫아도 켜져 있음을 늘 보이게 (사용자가 모르게 열려 있지 않게) + Widget _wifiBar(WifiShare s) { + final left = s.endsAt?.difference(DateTime.now()); + return Material( + color: JjColors.panelHigh, + child: InkWell( + onTap: () => WifiSharePage.open(context, c, s.root), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row(children: [ + const Icon(Icons.wifi_tethering, size: 18, color: JjColors.success), + const SizedBox(width: 6), + Expanded( + child: Text( + left == null + ? trf('Wi-Fi 공유 중 · 코드 {0} · {1}', [s.code, s.root]) + : trf('Wi-Fi 공유 중 · 코드 {0} · {1}분 남음 · {2}', [s.code, (left.inSeconds / 60).ceil().clamp(0, 9999), s.root]), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12)), + ), + TextButton(onPressed: () => s.stop(), child: Text(tr('끄기'))), + ]), + ), + ), + ); + } + /// 86: 압축 풀기 (검사 · 확인 · 진행 · 취소) 뒤 목록 새로 고침 Future _extractArchive(String path, _Pane pane) async { final other = _dual ? _panes[1 - _panes.indexOf(pane)].current : null; @@ -1444,6 +1486,8 @@ class _ExplorerPageState extends State { if (!dav) ('zip', Icons.archive_outlined, many ? trf('{0}개 ZIP 으로 묶기', [targets.length]) : tr('ZIP 으로 묶기')), // 87: 저장 공간 분석 (이 폴더 아래의 큰 폴더 · 큰 파일) if (e.isDir) ('storage', Icons.pie_chart_outline, tr('저장 공간 분석')), + // 89: 이 폴더를 Wi-Fi 로 공유 (이 기기의 폴더만) + if (e.isDir && !dav && !widget.rsync) ('wifiShare', Icons.wifi_tethering, tr('Wi-Fi 로 공유')), // 80: 이 폴더 크기를 지금 계산 (설정이 "안 함" 이어도) if (e.isDir) ('folderSize', Icons.data_usage, _sizes.busy(e.path) ? tr('폴더 크기 계산 멈추기') : tr('폴더 크기 계산')), if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), @@ -1537,6 +1581,8 @@ class _ExplorerPageState extends State { await _extractArchive(e.path, pane); case 'storage': await _storage(pane, e.path); + case 'wifiShare': + await _wifiShare(e.path); case 'zip': final items = outermostPaths(_isMarked(pane, e.path) && pane.marked.length > 1 ? pane.marked.toList() : [e.path]); final other = _dual ? _panes[1 - _panes.indexOf(pane)].current : null; @@ -1755,6 +1801,8 @@ class _ExplorerPageState extends State { await _search(pane); case ExplorerButton.storage: await _storage(pane, pane.current); + case ExplorerButton.wifiShare: + await _wifiShare(pane.current); case ExplorerButton.sort: await _sortDialog(); case ExplorerButton.hidden: @@ -3346,6 +3394,12 @@ class _ExplorerPageState extends State { return Column(children: [ _topBar(), if (_searchBack != null) _searchBackBar(), + // 89: Wi-Fi 공유가 켜져 있는 동안 + if (!widget.rsync) + ValueListenableBuilder( + valueListenable: WifiShare.current, + builder: (_, s, _) => s == null ? const SizedBox.shrink() : _wifiBar(s), + ), Expanded( child: Stack(children: [ Positioned.fill(child: _ready ? _body() : const Center(child: CircularProgressIndicator())), @@ -3383,6 +3437,7 @@ class _ExplorerPageState extends State { ExplorerButton.sort, ExplorerButton.hidden, if (!widget.rsync) ExplorerButton.storage, + if (!widget.rsync) ExplorerButton.wifiShare, ]) PopupMenuItem( value: b, diff --git a/lib/ui/wifi_share_page.dart b/lib/ui/wifi_share_page.dart new file mode 100644 index 0000000..173829a --- /dev/null +++ b/lib/ui/wifi_share_page.dart @@ -0,0 +1,262 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:qr/qr.dart'; + +import '../app/app_controller.dart'; +import '../core/wifi_share.dart'; +import '../l10n/tr.dart'; +import 'theme.dart'; + +/// 89: Wi-Fi 공유 화면 - 고른 폴더 하나를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용). +/// 켜기 전: 폴더 · 저절로 끄는 시간 · 안내 · [켜기]. 켠 뒤: 6자리 코드 (크게) · 주소 · QR · 남은 시간 [+30분] · [끄기] · 접속 기록. +/// 사용자 결정 (10/9): 기본 꺼짐 · 읽기 전용 · 비밀번호 (켤 때마다 새 코드). 화면을 닫아도 공유는 켜진 채 (탐색기 위 줄 · 알림으로 보임) +class WifiSharePage extends StatefulWidget { + final AppController c; + + /// 공유할 폴더 (이미 켜져 있으면 그 공유를 보인다) + final String folder; + const WifiSharePage({super.key, required this.c, required this.folder}); + + static Future open(BuildContext context, AppController c, String folder) => + Navigator.of(context).push(MaterialPageRoute(builder: (_) => WifiSharePage(c: c, folder: folder))); + + @override + State createState() => _WifiSharePageState(); +} + +class _WifiSharePageState extends State { + WifiShare? _s = WifiShare.instance; + Timer? _tick; + String? _error; + String? _lastReason; + bool _starting = false; + + @override + void initState() { + super.initState(); + _s?.addListener(_changed); + // 남은 시간 표시 + _tick = Timer.periodic(const Duration(seconds: 15), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _tick?.cancel(); + _s?.removeListener(_changed); + super.dispose(); + } + + void _changed() { + if (!mounted) return; + final s = _s; + if (s != null && !s.running) { + _lastReason = s.stopReason; + s.removeListener(_changed); + _s = null; + } + setState(() {}); + } + + Future _start() async { + setState(() { + _starting = true; + _error = null; + _lastReason = null; + }); + try { + final mins = widget.c.settings.wifiShareMinutes; + final s = await WifiShare.start(widget.folder, autoOff: mins > 0 ? Duration(minutes: mins) : null); + await widget.c.updateSettings((x) => x.wifiShareFolder = widget.folder); + _s?.removeListener(_changed); + _s = s..addListener(_changed); + // 작업 기록에는 켜고 끈 것만 (코드 · 주소 · 접속한 곳은 남기지 않는다) + widget.c.note(tr('Wi-Fi 공유를 켰습니다')); + } catch (e) { + _error = trf('켜지 못했습니다: {0}', [e is SocketException ? e.message : '$e']); + } finally { + if (mounted) setState(() => _starting = false); + } + } + + Future _stop() async { + await _s?.stop(); + widget.c.note(tr('Wi-Fi 공유를 껐습니다')); + } + + @override + Widget build(BuildContext context) { + final s = _s; + return Scaffold( + appBar: AppBar(title: Text(tr('Wi-Fi 공유'))), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: s == null ? _offView() : _onView(s), + ), + ), + ); + } + + List _offView() { + final mins = widget.c.settings.wifiShareMinutes; + return [ + Text(tr('공유할 폴더'), style: const TextStyle(color: JjColors.textDim, fontSize: 12)), + const SizedBox(height: 4), + SelectableText(widget.folder, style: const TextStyle(fontSize: 15)), + const SizedBox(height: 16), + Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 12, runSpacing: 4, children: [ + Text(tr('저절로 끄기')), + DropdownButton( + value: mins, + items: [ + for (final m in const [10, 30, 60, 120]) DropdownMenuItem(value: m, child: Text(trf('{0}분 뒤', [m]))), + DropdownMenuItem(value: 0, child: Text(tr('끄지 않음 (직접 끔)'))), + ], + onChanged: (v) async { + if (v == null) return; + await widget.c.updateSettings((x) => x.wifiShareMinutes = v); + if (mounted) setState(() {}); + }, + ), + ]), + const SizedBox(height: 12), + _note(Icons.lock_outline, tr('켤 때마다 6자리 코드를 새로 만듭니다. 주소와 코드를 아는 같은 Wi-Fi 의 기기만 이 폴더를 볼 수 있습니다.')), + _note(Icons.visibility_outlined, tr('읽기 전용입니다: 보기와 받기만 되고, 올리기 · 지우기 · 이름 바꾸기는 되지 않습니다. 이 폴더 밖과 링크는 열리지 않습니다.')), + if (Platform.isWindows) _note(Icons.shield_outlined, tr('처음 켤 때 Windows 방화벽 창이 뜨면 "개인 네트워크" 에 [허용] 을 누르세요.')), + if (_lastReason != null) _note(Icons.info_outline, _lastReason!, color: JjColors.danger), + if (_error != null) _note(Icons.error_outline, _error!, color: JjColors.danger), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerLeft, + child: FilledButton.icon( + onPressed: _starting ? null : _start, + icon: const Icon(Icons.wifi_tethering), + label: Text(tr('켜기')), + ), + ), + ]; + } + + List _onView(WifiShare s) { + final hosts = s.addresses.isEmpty ? [] : s.addresses; + final left = s.endsAt?.difference(DateTime.now()); + return [ + Row(children: [ + const Icon(Icons.wifi_tethering, color: JjColors.success), + const SizedBox(width: 8), + Expanded(child: Text(tr('공유 중'), style: const TextStyle(fontSize: 16, color: JjColors.success))), + FilledButton.icon( + style: FilledButton.styleFrom(backgroundColor: JjColors.danger), + onPressed: _stop, + icon: const Icon(Icons.stop), + label: Text(tr('끄기')), + ), + ]), + const SizedBox(height: 8), + SelectableText(s.root, style: const TextStyle(color: JjColors.textDim, fontSize: 12)), + const SizedBox(height: 16), + Text(tr('접속 코드'), style: const TextStyle(color: JjColors.textDim, fontSize: 12)), + SelectableText(s.code, style: const TextStyle(fontSize: 40, letterSpacing: 8, fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + if (hosts.isEmpty) + _note(Icons.wifi_off, tr('이 기기의 Wi-Fi 주소를 찾지 못했습니다. Wi-Fi 에 연결돼 있는지 확인하세요.'), color: JjColors.danger) + else ...[ + Text(tr('다른 기기의 브라우저에서 이 주소를 여세요'), style: const TextStyle(color: JjColors.textDim, fontSize: 12)), + for (final h in hosts) + Row(children: [ + Expanded(child: SelectableText('http://$h:${s.port}', style: const TextStyle(fontSize: 16))), + IconButton( + tooltip: tr('주소 복사'), + icon: const Icon(Icons.copy, size: 18), + onPressed: () => Clipboard.setData(ClipboardData(text: 'http://$h:${s.port}')), + ), + ]), + const SizedBox(height: 8), + Text(tr('또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)'), style: const TextStyle(color: JjColors.textDim, fontSize: 12)), + const SizedBox(height: 8), + Center(child: QrView(data: s.urlWithCode(hosts.first), size: 200)), + ], + const SizedBox(height: 12), + Row(children: [ + Expanded( + child: Text(left == null + ? tr('저절로 끄지 않습니다 - 다 쓰면 [끄기] 를 누르세요') + : trf('{0}분 뒤에 저절로 꺼집니다', [(left.inSeconds / 60).ceil().clamp(0, 9999)])), + ), + if (left != null) TextButton(onPressed: () => s.extend(const Duration(minutes: 30)), child: Text(tr('+30분'))), + ]), + const Divider(height: 32), + Text(trf('접속 기록 ({0}) - 이 화면에만 보이고 저장하지 않습니다', [s.log.length]), + style: const TextStyle(color: JjColors.textDim, fontSize: 12)), + if (s.log.isEmpty) + Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: Text(tr('아직 접속한 기기가 없습니다'), style: const TextStyle(color: JjColors.textDim))) + else + for (final a in s.log.take(50)) + Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Text( + '${_hm(a.at)} ${a.ip} ${a.status} ${Uri.decodeComponent(a.path)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: a.status >= 400 ? JjColors.danger : JjColors.text), + ), + ), + ]; + } + + static String _hm(DateTime t) => '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}'; + + Widget _note(IconData icon, String text, {Color color = JjColors.textDim}) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Icon(icon, size: 18, color: color), + const SizedBox(width: 8), + Expanded(child: Text(text, style: TextStyle(color: color == JjColors.textDim ? JjColors.text : color, fontSize: 13))), + ]), + ); +} + +/// 89: QR (흰 바탕 · 검은 칸, 둘레 여백 4칸 - 휴대폰 카메라가 읽게) +class QrView extends StatelessWidget { + final String data; + final double size; + const QrView({super.key, required this.data, this.size = 200}); + + @override + Widget build(BuildContext context) { + final img = QrImage(QrCode.fromData(data: data, errorCorrectLevel: QrErrorCorrectLevel.M)); + return Semantics( + label: tr('접속 QR'), + child: CustomPaint(size: Size.square(size), painter: _QrPainter(img)), + ); + } +} + +class _QrPainter extends CustomPainter { + final QrImage img; + _QrPainter(this.img); + + @override + void paint(Canvas canvas, Size size) { + const quiet = 4; + final n = img.moduleCount + quiet * 2; + final cell = size.width / n; + canvas.drawRect(Offset.zero & size, Paint()..color = Colors.white); + final dark = Paint()..color = Colors.black; + for (var r = 0; r < img.moduleCount; r++) { + for (var col = 0; col < img.moduleCount; col++) { + if (img.isDark(r, col)) { + canvas.drawRect(Rect.fromLTWH((col + quiet) * cell, (r + quiet) * cell, cell + 0.5, cell + 0.5), dark); + } + } + } + } + + @override + bool shouldRepaint(_QrPainter old) => old.img != img; +} diff --git a/pubspec.lock b/pubspec.lock index 06a276a..f810303 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -818,7 +818,7 @@ packages: source: hosted version: "2.2.1" qr: - dependency: transitive + dependency: "direct main" description: name: qr sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" diff --git a/pubspec.yaml b/pubspec.yaml index 4cd46d8..a2755dd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -69,6 +69,8 @@ dependencies: pdfrx: ^2.6.5 pdf: ^3.13.1 archive: ^4.3.0 + # 89: Wi-Fi 공유 접속 QR (BSD-3-Clause) + qr: ^3.0.2 image: ^4.10.1 # 보기 화면 "계속 보기" 동안 화면이 꺼지지 않게 (media_kit 이 이미 쓰는 것) wakelock_plus: ^1.8.0 diff --git a/test/keep_alive_test.dart b/test/keep_alive_test.dart index ca8014c..6172916 100644 --- a/test/keep_alive_test.dart +++ b/test/keep_alive_test.dart @@ -20,4 +20,9 @@ void main() { expect(AndroidKeepAlive.status(null, false, 1, 0.5, copies: 1, copyProgress: 0.1), ('복사 · 이동 1개 · 10% · 다운로드 1개 · 50%', 50), reason: '막대는 다운로드가 먼저'); }); + + test('89: Wi-Fi 공유가 켜져 있으면 알림줄에 (코드 · 주소는 넣지 않음)', () { + expect(AndroidKeepAlive.status(null, false, 0, null, wifiShare: true), ('Wi-Fi 공유 켜짐', -1)); + expect(AndroidKeepAlive.status(null, false, 1, null, wifiShare: true), ('다운로드 1개 · Wi-Fi 공유 켜짐', -1)); + }); } diff --git a/test/licenses_test.dart b/test/licenses_test.dart index 94b70a7..b050815 100644 --- a/test/licenses_test.dart +++ b/test/licenses_test.dart @@ -109,6 +109,12 @@ void main() { } expect(File('pubspec.lock').readAsStringSync(), matches(RegExp(r'\n archive:\r?\n(?: .*\r?\n){1,8}? version: "4\.3\.0"')), reason: 'archive 판이 바뀌면 고지의 판 번호도 고친다'); + // 89: Wi-Fi 공유 QR 은 qr 패키지 (BSD-3-Clause) - 두 고지에 이름으로 + for (final t in [notices, android]) { + expect(t, contains('qr 3.0.2')); + } + expect(File('pubspec.lock').readAsStringSync(), matches(RegExp(r'\n qr:\r?\n(?: .*\r?\n){1,8}? version: "3\.0\.2"')), + reason: 'qr 판이 바뀌면 고지의 판 번호도 고친다'); }); test('Windows 설치: 실행 파일 옆 · tools · rsync 에 원문을 둔다 (CMake)', () { diff --git a/test/wifi_share_page_test.dart b/test/wifi_share_page_test.dart new file mode 100644 index 0000000..3449d76 --- /dev/null +++ b/test/wifi_share_page_test.dart @@ -0,0 +1,60 @@ +import 'dart:io'; + +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/wifi_share.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/wifi_share_page.dart'; + +/// 89: Wi-Fi 공유 화면 - 처음엔 꺼짐 · [켜기] 뒤 6자리 코드 · 주소 · 남은 시간 · [끄기] 로 꺼짐. 설정 파일은 쓰지 않는다 (저장소 없음) +void main() { + late Directory tmp; + + setUp(() { + WifiShare.debugLoopbackOnly = true; + tmp = Directory.systemTemp.createTempSync('jj_wifipage_'); + File('${tmp.path}${Platform.pathSeparator}a.txt').writeAsStringSync('a'); + }); + + tearDown(() async { + await WifiShare.instance?.stop(); + WifiShare.debugLoopbackOnly = false; + try { + tmp.deleteSync(recursive: true); + } catch (_) {} + }); + + testWidgets('꺼짐 → [켜기] → 코드 · 남은 시간 → [끄기] → 꺼짐', (tester) async { + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + expect(c.settings.wifiShareMinutes, 30, reason: '처음 값 30분 (관리자 지시)'); + // 폰 세로처럼 좁고 긴 화면 (아래 줄까지 다 그려지게) + tester.view.physicalSize = const Size(412, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp(home: WifiSharePage(c: c, folder: tmp.path))); + expect(find.text('켜기'), findsOneWidget); + expect(find.text('30분 뒤'), findsOneWidget); + expect(WifiShare.instance, isNull, reason: '화면을 열기만 해서는 켜지지 않는다 (기본 꺼짐)'); + + await tester.tap(find.text('켜기')); + for (var i = 0; i < 20 && WifiShare.instance == null; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await tester.pump(); + } + await tester.pump(); + final s = WifiShare.instance!; + expect(find.text(s.code), findsOneWidget); + expect(find.textContaining('분 뒤에 저절로 꺼집니다'), findsOneWidget); + expect(find.text('아직 접속한 기기가 없습니다'), findsOneWidget); + expect(c.settings.wifiShareFolder, tmp.path); + + await tester.tap(find.text('끄기')); + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await tester.pump(); + expect(WifiShare.instance, isNull); + expect(find.text('켜기'), findsOneWidget); + }); +} diff --git a/test/wifi_share_test.dart b/test/wifi_share_test.dart new file mode 100644 index 0000000..286042e --- /dev/null +++ b/test/wifi_share_test.dart @@ -0,0 +1,204 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/wifi_share.dart'; +import 'package:path/path.dart' as p; + +/// 89: Wi-Fi 공유 서버 - 코드 없으면 401 · 틀리면 막기 · 폴더 밖 · 링크 403 · 읽기만 (405) · Range · 격리 · 저절로 끄기 +void main() { + late Directory tmp, shared; + late WifiShare s; + final client = HttpClient(); + + setUp(() async { + tmp = Directory.systemTemp.createTempSync('jj_wifi_'); + shared = Directory(p.join(tmp.path, 'shared'))..createSync(); + File(p.join(shared.path, 'a.txt')).writeAsStringSync('0123456789'); + File(p.join(shared.path, '한글 이름.srt')).writeAsStringSync('자막'); + File(p.join(shared.path, 'page.html')).writeAsStringSync(''); + Directory(p.join(shared.path, 'sub')).createSync(); + File(p.join(shared.path, 'sub', 'b.bin')).writeAsBytesSync([1, 2, 3]); + File(p.join(tmp.path, 'secret.txt')).writeAsStringSync('밖의 파일'); + s = await WifiShare.start(shared.path, port: 0, random: Random(7), loopbackOnly: true); + }); + + tearDown(() async { + await s.stop(); + for (var i = 0; i < 5 && tmp.existsSync(); i++) { + try { + tmp.deleteSync(recursive: true); + } on FileSystemException { + await Future.delayed(const Duration(milliseconds: 100)); + } + } + }); + + Future get(String path, {String? cookie, String method = 'GET', Map headers = const {}}) async { + final req = await client.openUrl(method, Uri.parse('http://127.0.0.1:${s.port}$path')); + req.followRedirects = false; + if (cookie != null) req.headers.set('Cookie', cookie); + headers.forEach(req.headers.set); + return req.close(); + } + + Future body(HttpClientResponse r) => r.transform(utf8.decoder).join(); + + Future login() async { + final r = await get('/?code=${s.code}'); + expect(r.statusCode, 303); + expect(r.headers.value('location'), '/', reason: '코드가 주소에 남지 않게'); + await r.drain(); + return r.cookies.where((c) => c.name == 'jjshare').map((c) => '${c.name}=${c.value}').single; + } + + /// HttpClient 는 ../ 를 미리 정리하므로 날 요청을 보낸다 (상태 코드 · 본문) + Future<(int, String)> raw(String path, String cookie) async { + final sock = await Socket.connect('127.0.0.1', s.port); + sock.write('GET $path HTTP/1.1\r\nHost: x\r\nCookie: $cookie\r\nConnection: close\r\n\r\n'); + final text = await utf8.decoder.bind(sock).join(); + sock.destroy(); + return (int.parse(text.split(' ')[1]), text); + } + + Future rawStatus(String path, String cookie) async => (await raw(path, cookie)).$1; + + test('코드: 6자리 · 없으면 401 · 틀리면 401 · 맞으면 쿠키로 목록', () async { + expect(s.code, matches(RegExp(r'^\d{6}$'))); + var r = await get('/'); + expect(r.statusCode, 401); + expect(await body(r), contains('name="code"')); + r = await get('/?code=${s.code == '000000' ? '111111' : '000000'}'); + expect(r.statusCode, 401); + expect(await body(r), contains('코드가 맞지 않습니다')); + final cookie = await login(); + r = await get('/', cookie: cookie); + expect(r.statusCode, 200); + final html = await body(r); + expect(html, contains('a.txt')); + expect(html, contains('한글 이름.srt')); + expect(html, contains('sub')); + expect(html, isNot(contains('secret.txt'))); + expect(r.headers.value('content-security-policy'), contains("default-src 'none'")); + // 다른 쿠키 값은 안 됨 + r = await get('/', cookie: 'jjshare=xxx'); + expect(r.statusCode, 401); + await r.drain(); + }); + + test('읽기만: GET · HEAD 말고는 405', () async { + final cookie = await login(); + for (final m in ['POST', 'PUT', 'DELETE', 'MKCOL', 'PROPFIND']) { + final r = await get('/a.txt', cookie: cookie, method: m); + expect(r.statusCode, 405, reason: m); + await r.drain(); + } + final r = await get('/a.txt', cookie: cookie, method: 'HEAD'); + expect(r.statusCode, 200); + await r.drain(); + expect(File(p.join(shared.path, 'a.txt')).readAsStringSync(), '0123456789'); + }); + + test('폴더 밖은 403 (../ · %2e%2e · 역슬래시 · 드라이브), 없는 것은 404', () async { + final cookie = await login(); + // 서버가 경로의 ../ 를 먼저 정리하면 (공유 폴더 안의) 없는 이름이 되어 404, 정리되지 않은 것은 403 - 어느 쪽이든 밖은 열리지 않는다 + for (final path in ['/../secret.txt', '/%2e%2e/secret.txt', '/sub/../../secret.txt', '/..%5csecret.txt', '/%2e%2e%5csecret.txt', '/C:%5cWindows']) { + final (code, text) = await raw(path, cookie); + expect(code, isIn([403, 404]), reason: path); + expect(text, isNot(contains('밖의 파일')), reason: path); + } + expect(await rawStatus('/..%5csecret.txt', cookie), 403, reason: '역슬래시는 정리되지 않으므로 막아야 함'); + expect(await rawStatus('/C:%5cWindows', cookie), 403); + expect(await rawStatus('/없는것.txt', cookie), 404); + expect(await rawStatus('/sub/b.bin', cookie), 200); + }); + + test('링크 · junction 은 목록에도 없고 열면 403', () async { + final link = Link(p.join(shared.path, 'out')); + try { + link.createSync(tmp.path); // 공유 폴더 밖 (secret.txt 가 있는 곳) + } catch (_) { + markTestSkipped('링크를 만들 수 없음 (권한)'); + return; + } + final cookie = await login(); + final r = await get('/', cookie: cookie); + expect(await body(r), isNot(contains('>📁 out<'))); + expect(await rawStatus('/out/secret.txt', cookie), 403); + expect(await rawStatus('/out/', cookie), 403); + }); + + test('파일: 한글 이름 · Range (동영상 건너뛰기) · 받기 · HTML 은 격리해 내려받기로', () async { + final cookie = await login(); + var r = await get('/${Uri.encodeComponent('한글 이름.srt')}', cookie: cookie); + expect(r.statusCode, 200); + expect(await body(r), '자막'); + r = await get('/a.txt', cookie: cookie, headers: {'Range': 'bytes=2-4'}); + expect(r.statusCode, 206); + expect(r.headers.value('content-range'), 'bytes 2-4/10'); + expect(await body(r), '234'); + r = await get('/a.txt', cookie: cookie, headers: {'Range': 'bytes=-3'}); + expect(await body(r), '789'); + r = await get('/a.txt', cookie: cookie, headers: {'Range': 'bytes=50-'}); + expect(r.statusCode, 416); + await r.drain(); + r = await get('/a.txt?dl=1', cookie: cookie); + expect(r.headers.value('content-disposition'), startsWith('attachment')); + await r.drain(); + r = await get('/page.html', cookie: cookie); + expect(r.headers.value('content-type'), 'application/octet-stream'); + expect(r.headers.value('content-disposition'), startsWith('attachment')); + expect(r.headers.value('content-security-policy'), 'sandbox'); + expect(r.headers.value('x-content-type-options'), 'nosniff'); + await r.drain(); + }); + + test('코드를 5번 틀리면 1분 막고 (429), 모두 30번이면 공유를 끈다', () async { + final wrong = s.code == '000000' ? '111111' : '000000'; + for (var i = 0; i < WifiShare.perClientFails; i++) { + final r = await get('/?code=$wrong'); + expect(r.statusCode, 401); + await r.drain(); + } + var r = await get('/?code=${s.code}'); + expect(r.statusCode, 429, reason: '막힌 동안은 맞는 코드도 안 됨'); + await r.drain(); + // 모두 합쳐 30번 (막힌 동안은 세지 않으므로 시험에서 바로 센다) + for (var i = WifiShare.perClientFails; i < WifiShare.totalFails; i++) { + s.debugFail('10.0.0.${i % 200}'); + } + await Future.delayed(const Duration(milliseconds: 50)); + expect(s.running, isFalse); + expect(s.stopReason, contains('너무 많이 틀려')); + expect(WifiShare.instance, isNull); + }); + + test('정한 시간이 지나면 저절로 꺼지고, 접속 기록에는 코드가 없다', () async { + final cookie = await login(); + final r = await get('/a.txt', cookie: cookie); + await r.drain(); + // 기록은 응답을 다 보낸 뒤에 적힌다 + for (var i = 0; i < 50 && !s.log.any((x) => x.path == '/a.txt'); i++) { + await Future.delayed(const Duration(milliseconds: 20)); + } + expect(s.log.map((x) => x.path), contains('/a.txt')); + expect(s.log.any((x) => x.path.contains(s.code)), isFalse); + await s.stop(); + final t = await WifiShare.start(shared.path, port: 0, autoOff: const Duration(milliseconds: 200), loopbackOnly: true); + expect(t.endsAt, isNotNull); + await Future.delayed(const Duration(milliseconds: 500)); + expect(t.running, isFalse); + expect(t.stopReason, contains('저절로')); + s = t; + }); + + test('같은 네트워크 판단: 사설망 · Tailscale 은 받고 공인 주소는 막는다', () { + for (final ip in ['192.168.0.5', '10.1.2.3', '172.16.0.1', '172.31.255.1', '100.69.29.106', '127.0.0.1', '169.254.1.1']) { + expect(WifiShare.isLocalNetwork(ip), isTrue, reason: ip); + } + for (final ip in ['8.8.8.8', '172.32.0.1', '100.128.0.1', '1.2.3.4', 'x']) { + expect(WifiShare.isLocalNetwork(ip), isFalse, reason: ip); + } + }); +}