AI 모델 받기: 이어 받기 · 화면이 꺼져도 계속 · 쉬운 실패 문구 (149) · 진행 · 차례 · 공간 표시 (121 받기 ①②③)
- 149: 받다 만 조각을 남겨 Range 로 받은 곳부터 이어 받고 (SHA-256 은 조각까지 합쳐 확인), 끊기면 저절로 몇 번 다시 잇는다. 실패는 "연결이 끊겼습니다 · [이어 받기]" 한 줄 (서명 주소는 보이지 않음). Android 작업 알림에 받는 진행 (화면이 꺼지거나 다른 앱으로 가도 계속). 라이선스 동의는 한 번만. - ① % · 받은 크기 / 전체 · 남은 시간, ② 한꺼번에 받을 때 "차례 기다림", ③ 필요한 공간 · 남은 공간을 늘 한 줄로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>묶음3
parent
7d6c653d5f
commit
92556f62ed
|
|
@ -64,6 +64,7 @@
|
||||||
"AI 그림 저장 폴더": "Folder for AI images",
|
"AI 그림 저장 폴더": "Folder for AI images",
|
||||||
"AI 그림: {0}": "AI images: {0}",
|
"AI 그림: {0}": "AI images: {0}",
|
||||||
"AI 그림으로 (이 그림을 바탕으로)": "To AI images (based on this picture)",
|
"AI 그림으로 (이 그림을 바탕으로)": "To AI images (based on this picture)",
|
||||||
|
"AI 모델 받는 중: {0}": "Downloading AI model: {0}",
|
||||||
"AI 번역": "AI translation",
|
"AI 번역": "AI translation",
|
||||||
"AI 설정 · 모델 받기 · 서비스 추가": "AI settings · download models · add services",
|
"AI 설정 · 모델 받기 · 서비스 추가": "AI settings · download models · add services",
|
||||||
"AI 엔진이 실패했습니다 ({0}): {1}": "The AI engine failed ({0}): {1}",
|
"AI 엔진이 실패했습니다 ({0}): {1}": "The AI engine failed ({0}): {1}",
|
||||||
|
|
@ -559,6 +560,7 @@
|
||||||
"다운로딩 {0}{1}": "Downloading {0}{1}",
|
"다운로딩 {0}{1}": "Downloading {0}{1}",
|
||||||
"다음": "Next",
|
"다음": "Next",
|
||||||
"다음 (N)": "Next (N)",
|
"다음 (N)": "Next (N)",
|
||||||
|
"다음 {0}개 기다림": "{0} more waiting",
|
||||||
"다음 페이지 (Alt+→)": "Next page (Alt+→)",
|
"다음 페이지 (Alt+→)": "Next page (Alt+→)",
|
||||||
"다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "Don't ask again (can be changed in Settings > Run)",
|
"다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "Don't ask again (can be changed in Settings > Run)",
|
||||||
"단계": "Steps",
|
"단계": "Steps",
|
||||||
|
|
@ -766,6 +768,7 @@
|
||||||
"받을 수 없습니다 ({0}): {1}": "Can't download ({0}): {1}",
|
"받을 수 없습니다 ({0}): {1}": "Can't download ({0}): {1}",
|
||||||
"받음": "Downloaded",
|
"받음": "Downloaded",
|
||||||
"받음 {0}": "Received {0}",
|
"받음 {0}": "Received {0}",
|
||||||
|
"받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요": "Couldn't download. Tap [Resume] in a moment",
|
||||||
"받지 못했습니다: {0}": "Could not download: {0}",
|
"받지 못했습니다: {0}": "Could not download: {0}",
|
||||||
"밝게": "Brighter",
|
"밝게": "Brighter",
|
||||||
"밝기": "Brightness",
|
"밝기": "Brightness",
|
||||||
|
|
@ -1064,6 +1067,8 @@
|
||||||
"연결 실패: {0}": "Connection failed: {0}",
|
"연결 실패: {0}": "Connection failed: {0}",
|
||||||
"연결 확인": "Test connection",
|
"연결 확인": "Test connection",
|
||||||
"연결됨: 항목 {0}개{1}": "Connected: {0} items{1}",
|
"연결됨: 항목 {0}개{1}": "Connected: {0} items{1}",
|
||||||
|
"연결이 끊겨 다시 잇는 중 ({0}번째)": "Connection lost, reconnecting (attempt {0})",
|
||||||
|
"연결이 끊겼습니다. [이어 받기] 를 누르면 받은 곳부터 이어 받습니다": "The connection was lost. Tap [Resume] to continue from where it stopped",
|
||||||
"연도": "Year",
|
"연도": "Year",
|
||||||
"열 수 없습니다: {0}": "Cannot open: {0}",
|
"열 수 없습니다: {0}": "Cannot open: {0}",
|
||||||
"열기": "Open",
|
"열기": "Open",
|
||||||
|
|
@ -1268,6 +1273,7 @@
|
||||||
"이미지 자막은 편집할 수 없습니다": "Image subtitles cannot be edited",
|
"이미지 자막은 편집할 수 없습니다": "Image subtitles cannot be edited",
|
||||||
"이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "Passwords entered this session will be lost when the app closes (they are not written to the settings file in plain text). Try pressing [Try again].",
|
"이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "Passwords entered this session will be lost when the app closes (they are not written to the settings file in plain text). Try pressing [Try again].",
|
||||||
"이번 업데이트로 바뀐 기본 설정": "Default settings changed by this update",
|
"이번 업데이트로 바뀐 기본 설정": "Default settings changed by this update",
|
||||||
|
"이어 받기": "Resume",
|
||||||
"이전": "Previous",
|
"이전": "Previous",
|
||||||
"이전 (P)": "Previous (P)",
|
"이전 (P)": "Previous (P)",
|
||||||
"이전 설정을 되살렸습니다": "Previous settings restored",
|
"이전 설정을 되살렸습니다": "Previous settings restored",
|
||||||
|
|
@ -1510,6 +1516,7 @@
|
||||||
"진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "A job is in progress: {0}\nStop it and quit?",
|
"진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "A job is in progress: {0}\nStop it and quit?",
|
||||||
"집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "If it uses a self-signed certificate (like a home NAS), turn on \"Don't verify certificate\" in [Edit server settings].",
|
"집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "If it uses a self-signed certificate (like a home NAS), turn on \"Don't verify certificate\" in [Edit server settings].",
|
||||||
"짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "Shorter means faster syncing but more battery use.",
|
"짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "Shorter means faster syncing but more battery use.",
|
||||||
|
"차례 기다림": "Waiting in queue",
|
||||||
"창": "Panes",
|
"창": "Panes",
|
||||||
"창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "Icon of the window, taskbar, tray and the top-left button in the app (the program file and desktop shortcut icons stay the same)",
|
"창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "Icon of the window, taskbar, tray and the top-left button in the app (the program file and desktop shortcut icons stay the same)",
|
||||||
"창 배치": "Pane layout",
|
"창 배치": "Pane layout",
|
||||||
|
|
@ -1684,6 +1691,8 @@
|
||||||
"필수 프로그램 점검": "Check required programs",
|
"필수 프로그램 점검": "Check required programs",
|
||||||
"필수 프로그램이 모두 설치되어 있습니다.": "All required programs are installed.",
|
"필수 프로그램이 모두 설치되어 있습니다.": "All required programs are installed.",
|
||||||
"필요한 것 모두 받기": "Download everything needed",
|
"필요한 것 모두 받기": "Download everything needed",
|
||||||
|
"필요한 공간 {0}": "Space needed {0}",
|
||||||
|
"필요한 공간 {0} · 남은 공간 {1}": "Space needed {0} · free {1}",
|
||||||
"하위 폴더가 없습니다": "No subfolders",
|
"하위 폴더가 없습니다": "No subfolders",
|
||||||
"한 번 더": "Once more",
|
"한 번 더": "Once more",
|
||||||
"한 번: 바로 실행": "Single: open right away",
|
"한 번: 바로 실행": "Single: open right away",
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@
|
||||||
"AI 그림 저장 폴더": "AI 画像の保存フォルダー",
|
"AI 그림 저장 폴더": "AI 画像の保存フォルダー",
|
||||||
"AI 그림: {0}": "AI 画像: {0}",
|
"AI 그림: {0}": "AI 画像: {0}",
|
||||||
"AI 그림으로 (이 그림을 바탕으로)": "AI 画像へ (この画像を元に)",
|
"AI 그림으로 (이 그림을 바탕으로)": "AI 画像へ (この画像を元に)",
|
||||||
|
"AI 모델 받는 중: {0}": "AI モデルをダウンロード中: {0}",
|
||||||
"AI 번역": "AI 翻訳",
|
"AI 번역": "AI 翻訳",
|
||||||
"AI 설정 · 모델 받기 · 서비스 추가": "AI 設定・モデルのダウンロード・サービス追加",
|
"AI 설정 · 모델 받기 · 서비스 추가": "AI 設定・モデルのダウンロード・サービス追加",
|
||||||
"AI 엔진이 실패했습니다 ({0}): {1}": "AI エンジンが失敗しました ({0}): {1}",
|
"AI 엔진이 실패했습니다 ({0}): {1}": "AI エンジンが失敗しました ({0}): {1}",
|
||||||
|
|
@ -559,6 +560,7 @@
|
||||||
"다운로딩 {0}{1}": "ダウンロード中 {0}{1}",
|
"다운로딩 {0}{1}": "ダウンロード中 {0}{1}",
|
||||||
"다음": "次へ",
|
"다음": "次へ",
|
||||||
"다음 (N)": "次へ(N)",
|
"다음 (N)": "次へ(N)",
|
||||||
|
"다음 {0}개 기다림": "次の {0} 件が待機中",
|
||||||
"다음 페이지 (Alt+→)": "次のページ(Alt+→)",
|
"다음 페이지 (Alt+→)": "次のページ(Alt+→)",
|
||||||
"다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "次回から確認しない(環境設定 > 実行 で変更できます)",
|
"다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "次回から確認しない(環境設定 > 実行 で変更できます)",
|
||||||
"단계": "ステップ",
|
"단계": "ステップ",
|
||||||
|
|
@ -766,6 +768,7 @@
|
||||||
"받을 수 없습니다 ({0}): {1}": "ダウンロードできません ({0}): {1}",
|
"받을 수 없습니다 ({0}): {1}": "ダウンロードできません ({0}): {1}",
|
||||||
"받음": "ダウンロード済み",
|
"받음": "ダウンロード済み",
|
||||||
"받음 {0}": "受信 {0}",
|
"받음 {0}": "受信 {0}",
|
||||||
|
"받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요": "ダウンロードできませんでした。少し待ってから [続きをダウンロード] を押してください",
|
||||||
"받지 못했습니다: {0}": "ダウンロードできませんでした: {0}",
|
"받지 못했습니다: {0}": "ダウンロードできませんでした: {0}",
|
||||||
"밝게": "明るく",
|
"밝게": "明るく",
|
||||||
"밝기": "明るさ",
|
"밝기": "明るさ",
|
||||||
|
|
@ -1064,6 +1067,8 @@
|
||||||
"연결 실패: {0}": "接続失敗: {0}",
|
"연결 실패: {0}": "接続失敗: {0}",
|
||||||
"연결 확인": "接続テスト",
|
"연결 확인": "接続テスト",
|
||||||
"연결됨: 항목 {0}개{1}": "接続しました: 項目 {0} 個{1}",
|
"연결됨: 항목 {0}개{1}": "接続しました: 項目 {0} 個{1}",
|
||||||
|
"연결이 끊겨 다시 잇는 중 ({0}번째)": "接続が切れたため再接続中 ({0} 回目)",
|
||||||
|
"연결이 끊겼습니다. [이어 받기] 를 누르면 받은 곳부터 이어 받습니다": "接続が切れました。[続きをダウンロード] を押すと続きからダウンロードします",
|
||||||
"연도": "年",
|
"연도": "年",
|
||||||
"열 수 없습니다: {0}": "開けません: {0}",
|
"열 수 없습니다: {0}": "開けません: {0}",
|
||||||
"열기": "開く",
|
"열기": "開く",
|
||||||
|
|
@ -1268,6 +1273,7 @@
|
||||||
"이미지 자막은 편집할 수 없습니다": "画像字幕は編集できません",
|
"이미지 자막은 편집할 수 없습니다": "画像字幕は編集できません",
|
||||||
"이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "今回入力したパスワードはアプリを終了すると消えます (設定ファイルに平文で書きません)。[再試行] を押してみてください。",
|
"이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "今回入力したパスワードはアプリを終了すると消えます (設定ファイルに平文で書きません)。[再試行] を押してみてください。",
|
||||||
"이번 업데이트로 바뀐 기본 설정": "今回のアップデートで変わった既定の設定",
|
"이번 업데이트로 바뀐 기본 설정": "今回のアップデートで変わった既定の設定",
|
||||||
|
"이어 받기": "続きをダウンロード",
|
||||||
"이전": "前へ",
|
"이전": "前へ",
|
||||||
"이전 (P)": "前へ(P)",
|
"이전 (P)": "前へ(P)",
|
||||||
"이전 설정을 되살렸습니다": "以前の設定を復元しました",
|
"이전 설정을 되살렸습니다": "以前の設定を復元しました",
|
||||||
|
|
@ -1510,6 +1516,7 @@
|
||||||
"진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "進行中の作業があります: {0}\n中止して終了しますか?",
|
"진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "進行中の作業があります: {0}\n中止して終了しますか?",
|
||||||
"집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "自宅 NAS のように自己署名証明書なら、[サーバー設定を修正] で「証明書を検証しない」をオンにしてください。",
|
"집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "自宅 NAS のように自己署名証明書なら、[サーバー設定を修正] で「証明書を検証しない」をオンにしてください。",
|
||||||
"짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "短いほど早く同期しますが、バッテリーをより使います。",
|
"짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "短いほど早く同期しますが、バッテリーをより使います。",
|
||||||
|
"차례 기다림": "順番待ち",
|
||||||
"창": "ペイン",
|
"창": "ペイン",
|
||||||
"창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "ウィンドウ・タスクバー・トレイとアプリ内左上のボタンのアイコンです(実行ファイル・デスクトップのショートカットのアイコンはそのまま)",
|
"창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "ウィンドウ・タスクバー・トレイとアプリ内左上のボタンのアイコンです(実行ファイル・デスクトップのショートカットのアイコンはそのまま)",
|
||||||
"창 배치": "ペインの配置",
|
"창 배치": "ペインの配置",
|
||||||
|
|
@ -1684,6 +1691,8 @@
|
||||||
"필수 프로그램 점검": "必須プログラムの点検",
|
"필수 프로그램 점검": "必須プログラムの点検",
|
||||||
"필수 프로그램이 모두 설치되어 있습니다.": "必須プログラムはすべてインストールされています。",
|
"필수 프로그램이 모두 설치되어 있습니다.": "必須プログラムはすべてインストールされています。",
|
||||||
"필요한 것 모두 받기": "必要なものをすべてダウンロード",
|
"필요한 것 모두 받기": "必要なものをすべてダウンロード",
|
||||||
|
"필요한 공간 {0}": "必要な容量 {0}",
|
||||||
|
"필요한 공간 {0} · 남은 공간 {1}": "必要な容量 {0}・空き容量 {1}",
|
||||||
"하위 폴더가 없습니다": "サブフォルダーがありません",
|
"하위 폴더가 없습니다": "サブフォルダーがありません",
|
||||||
"한 번 더": "もう一度",
|
"한 번 더": "もう一度",
|
||||||
"한 번: 바로 실행": "1 回: すぐに開く",
|
"한 번: 바로 실행": "1 回: すぐに開く",
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@
|
||||||
"AI 그림 저장 폴더": "AI 图片保存文件夹",
|
"AI 그림 저장 폴더": "AI 图片保存文件夹",
|
||||||
"AI 그림: {0}": "AI 图片: {0}",
|
"AI 그림: {0}": "AI 图片: {0}",
|
||||||
"AI 그림으로 (이 그림을 바탕으로)": "转到 AI 图片 (以此图片为基础)",
|
"AI 그림으로 (이 그림을 바탕으로)": "转到 AI 图片 (以此图片为基础)",
|
||||||
|
"AI 모델 받는 중: {0}": "正在下载 AI 模型: {0}",
|
||||||
"AI 번역": "AI 翻译",
|
"AI 번역": "AI 翻译",
|
||||||
"AI 설정 · 모델 받기 · 서비스 추가": "AI 设置 · 下载模型 · 添加服务",
|
"AI 설정 · 모델 받기 · 서비스 추가": "AI 设置 · 下载模型 · 添加服务",
|
||||||
"AI 엔진이 실패했습니다 ({0}): {1}": "AI 引擎失败 ({0}): {1}",
|
"AI 엔진이 실패했습니다 ({0}): {1}": "AI 引擎失败 ({0}): {1}",
|
||||||
|
|
@ -559,6 +560,7 @@
|
||||||
"다운로딩 {0}{1}": "正在下载 {0}{1}",
|
"다운로딩 {0}{1}": "正在下载 {0}{1}",
|
||||||
"다음": "下一个",
|
"다음": "下一个",
|
||||||
"다음 (N)": "下一个(N)",
|
"다음 (N)": "下一个(N)",
|
||||||
|
"다음 {0}개 기다림": "另有 {0} 个等待中",
|
||||||
"다음 페이지 (Alt+→)": "下一页(Alt+→)",
|
"다음 페이지 (Alt+→)": "下一页(Alt+→)",
|
||||||
"다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "下次不再询问(可在 设置 > 运行 中更改)",
|
"다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "下次不再询问(可在 设置 > 运行 中更改)",
|
||||||
"단계": "步数",
|
"단계": "步数",
|
||||||
|
|
@ -766,6 +768,7 @@
|
||||||
"받을 수 없습니다 ({0}): {1}": "无法下载 ({0}): {1}",
|
"받을 수 없습니다 ({0}): {1}": "无法下载 ({0}): {1}",
|
||||||
"받음": "已下载",
|
"받음": "已下载",
|
||||||
"받음 {0}": "已接收 {0}",
|
"받음 {0}": "已接收 {0}",
|
||||||
|
"받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요": "下载失败。请稍后点按 [继续下载]",
|
||||||
"받지 못했습니다: {0}": "无法下载: {0}",
|
"받지 못했습니다: {0}": "无法下载: {0}",
|
||||||
"밝게": "调亮",
|
"밝게": "调亮",
|
||||||
"밝기": "亮度",
|
"밝기": "亮度",
|
||||||
|
|
@ -1064,6 +1067,8 @@
|
||||||
"연결 실패: {0}": "连接失败: {0}",
|
"연결 실패: {0}": "连接失败: {0}",
|
||||||
"연결 확인": "测试连接",
|
"연결 확인": "测试连接",
|
||||||
"연결됨: 항목 {0}개{1}": "已连接: {0} 个项目{1}",
|
"연결됨: 항목 {0}개{1}": "已连接: {0} 个项目{1}",
|
||||||
|
"연결이 끊겨 다시 잇는 중 ({0}번째)": "连接中断,正在重新连接 (第 {0} 次)",
|
||||||
|
"연결이 끊겼습니다. [이어 받기] 를 누르면 받은 곳부터 이어 받습니다": "连接已中断。点按 [继续下载] 将从中断处继续",
|
||||||
"연도": "年份",
|
"연도": "年份",
|
||||||
"열 수 없습니다: {0}": "无法打开: {0}",
|
"열 수 없습니다: {0}": "无法打开: {0}",
|
||||||
"열기": "打开",
|
"열기": "打开",
|
||||||
|
|
@ -1268,6 +1273,7 @@
|
||||||
"이미지 자막은 편집할 수 없습니다": "图像字幕无法编辑",
|
"이미지 자막은 편집할 수 없습니다": "图像字幕无法编辑",
|
||||||
"이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "本次输入的密码在关闭应用后会丢失 (不会以明文写入设置文件)。请尝试按 [重试]。",
|
"이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "本次输入的密码在关闭应用后会丢失 (不会以明文写入设置文件)。请尝试按 [重试]。",
|
||||||
"이번 업데이트로 바뀐 기본 설정": "本次更新更改的默认设置",
|
"이번 업데이트로 바뀐 기본 설정": "本次更新更改的默认设置",
|
||||||
|
"이어 받기": "继续下载",
|
||||||
"이전": "上一个",
|
"이전": "上一个",
|
||||||
"이전 (P)": "上一个(P)",
|
"이전 (P)": "上一个(P)",
|
||||||
"이전 설정을 되살렸습니다": "已恢复之前的设置",
|
"이전 설정을 되살렸습니다": "已恢复之前的设置",
|
||||||
|
|
@ -1510,6 +1516,7 @@
|
||||||
"진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "有正在进行的任务: {0}\n要停止并退出吗?",
|
"진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "有正在进行的任务: {0}\n要停止并退出吗?",
|
||||||
"집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "如果是自签名证书 (如家用 NAS),请在 [修改服务器设置] 中开启“不验证证书”。",
|
"집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "如果是自签名证书 (如家用 NAS),请在 [修改服务器设置] 中开启“不验证证书”。",
|
||||||
"짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "间隔越短同步越快,但更耗电。",
|
"짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "间隔越短同步越快,但更耗电。",
|
||||||
|
"차례 기다림": "排队等待",
|
||||||
"창": "窗格",
|
"창": "窗格",
|
||||||
"창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "窗口、任务栏、托盘以及应用内左上角按钮的图标(程序文件和桌面快捷方式的图标不变)",
|
"창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "窗口、任务栏、托盘以及应用内左上角按钮的图标(程序文件和桌面快捷方式的图标不变)",
|
||||||
"창 배치": "窗格布局",
|
"창 배치": "窗格布局",
|
||||||
|
|
@ -1684,6 +1691,8 @@
|
||||||
"필수 프로그램 점검": "检查必需程序",
|
"필수 프로그램 점검": "检查必需程序",
|
||||||
"필수 프로그램이 모두 설치되어 있습니다.": "所有必需程序均已安装。",
|
"필수 프로그램이 모두 설치되어 있습니다.": "所有必需程序均已安装。",
|
||||||
"필요한 것 모두 받기": "下载全部所需文件",
|
"필요한 것 모두 받기": "下载全部所需文件",
|
||||||
|
"필요한 공간 {0}": "所需空间 {0}",
|
||||||
|
"필요한 공간 {0} · 남은 공간 {1}": "所需空间 {0} · 剩余空间 {1}",
|
||||||
"하위 폴더가 없습니다": "没有子文件夹",
|
"하위 폴더가 없습니다": "没有子文件夹",
|
||||||
"한 번 더": "再输入一次",
|
"한 번 더": "再输入一次",
|
||||||
"한 번: 바로 실행": "单击: 立即打开",
|
"한 번: 바로 실행": "单击: 立即打开",
|
||||||
|
|
|
||||||
|
|
@ -39,36 +39,96 @@ class AiStore extends ChangeNotifier {
|
||||||
|
|
||||||
bool isFileInstalled(AiFile f) => _mark(f).existsSync() && FileSystemEntity.typeSync(pathOf(f)) != FileSystemEntityType.notFound;
|
bool isFileInstalled(AiFile f) => _mark(f).existsSync() && FileSystemEntity.typeSync(pathOf(f)) != FileSystemEntityType.notFound;
|
||||||
|
|
||||||
|
/// 받는 중인 것 (id → 0~1, 모르면 null)
|
||||||
/// 받는 중인 것 (id → 0~1, 모르면 null)
|
/// 받는 중인 것 (id → 0~1, 모르면 null)
|
||||||
final progress = <String, double?>{};
|
final progress = <String, double?>{};
|
||||||
|
|
||||||
|
/// 받은 바이트 · 전체 · 이번에 받기 시작한 때와 그때 이미 있던 바이트 (속도 · 남은 시간)
|
||||||
|
final received = <String, int>{};
|
||||||
|
final totals = <String, int>{};
|
||||||
|
final _started = <String, (DateTime, int)>{};
|
||||||
|
|
||||||
|
/// [installAll] 로 차례를 기다리는 것 (149-②: "차례 기다림")
|
||||||
|
final queued = <String>{};
|
||||||
|
|
||||||
|
/// 연결이 끊겨 다시 잇는 중 (몇 번째)
|
||||||
|
final retrying = <String, int>{};
|
||||||
|
|
||||||
|
/// 마지막 실패 (id → 쉬운 말). 다시 받으면 지운다
|
||||||
|
final failures = <String, String>{};
|
||||||
final _cancel = <String>{};
|
final _cancel = <String>{};
|
||||||
|
|
||||||
void cancel(String id) => _cancel.add(id);
|
void cancel(String id) {
|
||||||
|
_cancel.add(id);
|
||||||
|
queued.remove(id);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// 받기 (SHA-256 확인). 저장 공간이 모자라면 먼저 알린다. zip (실행 파일) 은 푼다.
|
/// 받기 (SHA-256 확인). 저장 공간이 모자라면 먼저 알린다. zip (실행 파일) 은 푼다.
|
||||||
Future<void> install(String id) => installFile(aiFile(id));
|
Future<void> install(String id) => installFile(aiFile(id));
|
||||||
|
|
||||||
/// [install] 의 본체 (시험은 카탈로그 밖의 파일로)
|
/// 여러 개를 차례로 (기다리는 것은 [queued] 로 보인다). 하나가 실패하면 멈추고 알린다
|
||||||
Future<void> installFile(AiFile f) async {
|
Future<void> installAll(List<AiFile> files) async {
|
||||||
|
final todo = [for (final f in files) if (!isFileInstalled(f) && !progress.containsKey(f.id)) f];
|
||||||
|
queued.addAll(todo.map((f) => f.id));
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
for (final f in todo) {
|
||||||
|
if (!queued.contains(f.id)) continue; // 기다리는 동안 취소
|
||||||
|
queued.remove(f.id);
|
||||||
|
await installFile(f);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
queued.removeAll(todo.map((f) => f.id));
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 받을 때 필요한 공간 (zip 은 푸는 자리까지) - 이미 받아 둔 조각만큼은 빼고
|
||||||
|
int neededSpace(AiFile f) {
|
||||||
|
final part = _partOf(f);
|
||||||
|
final have = part.existsSync() ? part.lengthSync() : 0;
|
||||||
|
return (f.kind == 'engine' ? f.size * 3 : f.size) - have;
|
||||||
|
}
|
||||||
|
|
||||||
|
File _partOf(AiFile f) => File('${pathOf(f)}.part${f.kind == 'engine' ? '.zip' : ''}');
|
||||||
|
|
||||||
|
/// [install] 의 본체 (시험은 카탈로그 밖의 파일로).
|
||||||
|
/// 149: 끊기면 받은 곳부터 이어 받고 (조각 파일을 남겨 둠), 몇 번 저절로 다시 잇는다. 실패는 쉬운 말로 (주소는 보이지 않음).
|
||||||
|
Future<void> installFile(AiFile f, {int retries = 5, Duration retryWait = const Duration(seconds: 3)}) async {
|
||||||
final id = f.id;
|
final id = f.id;
|
||||||
if (progress.containsKey(id)) return;
|
if (progress.containsKey(id)) return;
|
||||||
_cancel.remove(id);
|
_cancel.remove(id);
|
||||||
|
failures.remove(id);
|
||||||
await Directory(root).create(recursive: true);
|
await Directory(root).create(recursive: true);
|
||||||
final free = await diskSpace(root);
|
final free = await diskSpace(root);
|
||||||
// 받는 파일 + 푸는 자리 (zip 은 두 배쯤) 와 여유 200MB
|
final need = neededSpace(f) + (200 << 20); // 여유 200MB
|
||||||
final need = (f.kind == 'engine' ? f.size * 3 : f.size) + (200 << 20);
|
|
||||||
if (free != null && free.$1 < need) {
|
if (free != null && free.$1 < need) {
|
||||||
throw ImageAiException(trf('저장 공간이 모자랍니다: {0} 이 필요한데 {1} 남았습니다', [_gb(need), _gb(free.$1)]));
|
throw ImageAiException(trf('저장 공간이 모자랍니다: {0} 이 필요한데 {1} 남았습니다', [_gb(need), _gb(free.$1)]));
|
||||||
}
|
}
|
||||||
|
final part = _partOf(f);
|
||||||
progress[id] = 0;
|
progress[id] = 0;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
final part = File('${pathOf(f)}.part${f.kind == 'engine' ? '.zip' : ''}');
|
|
||||||
try {
|
try {
|
||||||
await part.parent.create(recursive: true);
|
await part.parent.create(recursive: true);
|
||||||
await _download(f, part, (d) {
|
for (var attempt = 0;; attempt++) {
|
||||||
progress[id] = d;
|
try {
|
||||||
|
await _download(f, part);
|
||||||
|
break;
|
||||||
|
} on ImageAiException {
|
||||||
|
rethrow; // 취소 · 손상 (다시 받아도 같음)
|
||||||
|
} catch (e) {
|
||||||
|
if (_cancel.contains(id) || attempt >= retries) {
|
||||||
|
final msg = tr('연결이 끊겼습니다. [이어 받기] 를 누르면 받은 곳부터 이어 받습니다');
|
||||||
|
failures[id] = msg;
|
||||||
|
throw ImageAiException(msg);
|
||||||
|
}
|
||||||
|
retrying[id] = attempt + 1;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
});
|
await Future<void>.delayed(retryWait * (attempt + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
retrying.remove(id);
|
||||||
if (f.kind == 'engine') {
|
if (f.kind == 'engine') {
|
||||||
final dir = Directory(pathOf(f));
|
final dir = Directory(pathOf(f));
|
||||||
if (await dir.exists()) await dir.delete(recursive: true);
|
if (await dir.exists()) await dir.delete(recursive: true);
|
||||||
|
|
@ -81,13 +141,12 @@ class AiStore extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
await _mark(f).parent.create(recursive: true);
|
await _mark(f).parent.create(recursive: true);
|
||||||
await _mark(f).writeAsString(jsonEncode({'sha256': f.sha256, 'installed': DateTime.now().toIso8601String()}));
|
await _mark(f).writeAsString(jsonEncode({'sha256': f.sha256, 'installed': DateTime.now().toIso8601String()}));
|
||||||
} catch (_) {
|
|
||||||
try {
|
|
||||||
if (await part.exists()) await part.delete();
|
|
||||||
} catch (_) {}
|
|
||||||
rethrow;
|
|
||||||
} finally {
|
} finally {
|
||||||
progress.remove(id);
|
progress.remove(id);
|
||||||
|
received.remove(id);
|
||||||
|
totals.remove(id);
|
||||||
|
_started.remove(id);
|
||||||
|
retrying.remove(id);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -100,33 +159,108 @@ class AiStore extends ChangeNotifier {
|
||||||
final t = FileSystemEntity.typeSync(pathOf(f));
|
final t = FileSystemEntity.typeSync(pathOf(f));
|
||||||
if (t == FileSystemEntityType.directory) await Directory(pathOf(f)).delete(recursive: true);
|
if (t == FileSystemEntityType.directory) await Directory(pathOf(f)).delete(recursive: true);
|
||||||
if (t == FileSystemEntityType.file) await File(pathOf(f)).delete();
|
if (t == FileSystemEntityType.file) await File(pathOf(f)).delete();
|
||||||
|
try {
|
||||||
|
final part = _partOf(f);
|
||||||
|
if (part.existsSync()) part.deleteSync();
|
||||||
|
} catch (_) {}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _gb(int b) => b >= 1 << 30 ? '${(b / (1 << 30)).toStringAsFixed(1)}GB' : '${(b / (1 << 20)).round()}MB';
|
static String _gb(int b) => b >= 1 << 30 ? '${(b / (1 << 30)).toStringAsFixed(1)}GB' : '${(b / (1 << 20)).round()}MB';
|
||||||
static String sizeText(int b) => _gb(b);
|
static String sizeText(int b) => _gb(b);
|
||||||
|
|
||||||
Future<void> _download(AiFile f, File out, void Function(double? d) onProgress) async {
|
/// 진행 한 줄: "58% · 2.3GB / 4.0GB · 남은 약 5분" (149-①)
|
||||||
|
String? progressText(String id) {
|
||||||
|
if (queued.contains(id)) return tr('차례 기다림');
|
||||||
|
if (!progress.containsKey(id)) return null;
|
||||||
|
final got = received[id] ?? 0, total = totals[id] ?? 0;
|
||||||
|
final retry = retrying[id];
|
||||||
|
final left = eta(id);
|
||||||
|
return [
|
||||||
|
if (total > 0) '${(got * 100 / total).floor()}%',
|
||||||
|
if (total > 0) '${_gb(got)} / ${_gb(total)}',
|
||||||
|
if (left != null) trf('남은 약 {0}', [AiJobs.durationText(left)]),
|
||||||
|
if (retry != null) trf('연결이 끊겨 다시 잇는 중 ({0}번째)', [retry]),
|
||||||
|
].join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 남은 시간 (이번에 받은 속도로)
|
||||||
|
Duration? eta(String id) {
|
||||||
|
final s = _started[id];
|
||||||
|
final got = received[id], total = totals[id];
|
||||||
|
if (s == null || got == null || total == null || total <= 0) return null;
|
||||||
|
final secs = DateTime.now().difference(s.$1).inMilliseconds / 1000;
|
||||||
|
final rate = (got - s.$2) / (secs <= 0 ? 1 : secs);
|
||||||
|
if (secs < 3 || rate <= 0) return null;
|
||||||
|
return Duration(seconds: ((total - got) / rate).round());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 받는 중인 것을 한 줄로 (작업 알림 - 화면이 꺼져도 계속 받는 동안)
|
||||||
|
String? get statusLine {
|
||||||
|
if (progress.isEmpty) return null;
|
||||||
|
final id = progress.keys.first;
|
||||||
|
final name = aiCatalog.where((f) => f.id == id).firstOrNull?.name ?? id;
|
||||||
|
final more = queued.length;
|
||||||
|
return '${trf('AI 모델 받는 중: {0}', [tr(name)])} · ${progressText(id) ?? ''}'
|
||||||
|
'${more > 0 ? ' · ${trf('다음 {0}개 기다림', [more])}' : ''}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 조각 파일 [out] 이 있으면 그 뒤부터 (Range) 받는다. 서버가 이어 주지 않으면 처음부터.
|
||||||
|
/// SHA-256 은 이미 받은 조각까지 합쳐 확인한다.
|
||||||
|
Future<void> _download(AiFile f, File out) async {
|
||||||
|
var have = out.existsSync() ? out.lengthSync() : 0;
|
||||||
|
if (have > f.size) {
|
||||||
|
out.deleteSync();
|
||||||
|
have = 0;
|
||||||
|
}
|
||||||
final req = await _http.getUrl(Uri.parse(f.url));
|
final req = await _http.getUrl(Uri.parse(f.url));
|
||||||
|
if (have > 0) req.headers.set(HttpHeaders.rangeHeader, 'bytes=$have-');
|
||||||
final res = await req.close();
|
final res = await req.close();
|
||||||
if (res.statusCode != 200) throw ImageAiException(trf('받을 수 없습니다 ({0}): {1}', [res.statusCode, f.url]));
|
final whole = res.statusCode == 416 && have == f.size; // 이미 다 받아 둠 - 확인만
|
||||||
final total = res.contentLength > 0 ? res.contentLength : f.size;
|
if (whole) {
|
||||||
final sink = out.openWrite();
|
await res.drain<void>();
|
||||||
|
} else if (res.statusCode == 200) {
|
||||||
|
have = 0; // 이어 주지 않음 - 처음부터
|
||||||
|
} else if (res.statusCode != 206) {
|
||||||
|
await res.drain<void>();
|
||||||
|
throw HttpException('status ${res.statusCode}'); // 다시 시도 대상 (주소는 사용자에게 보이지 않음)
|
||||||
|
}
|
||||||
|
final total = f.size;
|
||||||
final hash = AccumulatorSink<Digest>();
|
final hash = AccumulatorSink<Digest>();
|
||||||
final hasher = sha256.startChunkedConversion(hash);
|
final hasher = sha256.startChunkedConversion(hash);
|
||||||
var done = 0;
|
if (have > 0) {
|
||||||
|
await for (final c in out.openRead(0, have)) {
|
||||||
|
hasher.add(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
received[f.id] = have;
|
||||||
|
totals[f.id] = total;
|
||||||
|
_started[f.id] = (DateTime.now(), have);
|
||||||
|
notifyListeners();
|
||||||
|
final sink = out.openWrite(mode: have > 0 ? FileMode.append : FileMode.write);
|
||||||
|
var done = have;
|
||||||
|
var lastNotify = DateTime.now();
|
||||||
try {
|
try {
|
||||||
|
if (!whole) {
|
||||||
await for (final chunk in res) {
|
await for (final chunk in res) {
|
||||||
if (_cancel.contains(f.id)) throw ImageAiException(tr('취소했습니다'));
|
if (_cancel.contains(f.id)) throw ImageAiException(tr('취소했습니다'));
|
||||||
sink.add(chunk);
|
sink.add(chunk);
|
||||||
hasher.add(chunk);
|
hasher.add(chunk);
|
||||||
done += chunk.length;
|
done += chunk.length;
|
||||||
onProgress(total > 0 ? done / total : null);
|
received[f.id] = done;
|
||||||
|
progress[f.id] = total > 0 ? done / total : null;
|
||||||
|
if (DateTime.now().difference(lastNotify).inMilliseconds > 250) {
|
||||||
|
lastNotify = DateTime.now();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
await sink.flush();
|
||||||
await sink.close();
|
await sink.close();
|
||||||
hasher.close();
|
hasher.close();
|
||||||
}
|
}
|
||||||
|
if (done < total) throw HttpException('short read $done/$total'); // 끊김 - 이어 받기
|
||||||
if (hash.events.single.toString() != f.sha256) {
|
if (hash.events.single.toString() != f.sha256) {
|
||||||
await out.delete();
|
await out.delete();
|
||||||
throw ImageAiException(trf('받은 파일이 손상되었습니다 (SHA-256 다름): {0}', [f.name]));
|
throw ImageAiException(trf('받은 파일이 손상되었습니다 (SHA-256 다름): {0}', [f.name]));
|
||||||
|
|
@ -408,6 +542,8 @@ class AiJobs extends ChangeNotifier {
|
||||||
return '$t${remaining > 1 ? ' · ${trf('남은 {0}장', [remaining])}' : ''}$left';
|
return '$t${remaining > 1 ? ' · ${trf('남은 {0}장', [remaining])}' : ''}$left';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String durationText(Duration d) => _dur(d);
|
||||||
|
|
||||||
static String _dur(Duration d) => d.inMinutes >= 1 ? trf('{0}분', [d.inMinutes + (d.inSeconds % 60 >= 30 ? 1 : 0)]) : trf('{0}초', [d.inSeconds]);
|
static String _dur(Duration d) => d.inMinutes >= 1 ? trf('{0}분', [d.inMinutes + (d.inSeconds % 60 >= 30 ? 1 : 0)]) : trf('{0}초', [d.inSeconds]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -471,6 +471,9 @@ class AppSettings {
|
||||||
/// 사용자가 추가한 그림 서버 · 서비스 (API 키는 안전 저장소에)
|
/// 사용자가 추가한 그림 서버 · 서비스 (API 키는 안전 저장소에)
|
||||||
List<AiService> aiServices = [];
|
List<AiService> aiServices = [];
|
||||||
|
|
||||||
|
/// 라이선스 조건에 동의한 받는 파일 (149-④: 한 번 동의하면 다시 묻지 않음)
|
||||||
|
List<String> aiAgreed = [];
|
||||||
|
|
||||||
/// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다.
|
/// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다.
|
||||||
int liveSyncIntervalSec = 30;
|
int liveSyncIntervalSec = 30;
|
||||||
|
|
||||||
|
|
@ -640,6 +643,7 @@ class AppSettings {
|
||||||
'aiStrength': aiStrength,
|
'aiStrength': aiStrength,
|
||||||
'aiNegative': aiNegative,
|
'aiNegative': aiNegative,
|
||||||
'aiSaveDir': aiSaveDir,
|
'aiSaveDir': aiSaveDir,
|
||||||
|
'aiAgreed': aiAgreed,
|
||||||
'aiServices': [
|
'aiServices': [
|
||||||
for (final x in aiServices) {...x.toJson(), if (plainSecrets) 'apiKey': x.apiKey},
|
for (final x in aiServices) {...x.toJson(), if (plainSecrets) 'apiKey': x.apiKey},
|
||||||
],
|
],
|
||||||
|
|
@ -818,6 +822,7 @@ class AppSettings {
|
||||||
..aiStrength = ((j['aiStrength'] as num?)?.toDouble() ?? 0.6).clamp(0, 1)
|
..aiStrength = ((j['aiStrength'] as num?)?.toDouble() ?? 0.6).clamp(0, 1)
|
||||||
..aiNegative = j['aiNegative'] as String? ?? ''
|
..aiNegative = j['aiNegative'] as String? ?? ''
|
||||||
..aiSaveDir = j['aiSaveDir'] as String? ?? ''
|
..aiSaveDir = j['aiSaveDir'] as String? ?? ''
|
||||||
|
..aiAgreed = [for (final x in (j['aiAgreed'] as List?) ?? const []) '$x']
|
||||||
..aiServices = [
|
..aiServices = [
|
||||||
for (final x in (j['aiServices'] as List?) ?? const [])
|
for (final x in (j['aiServices'] as List?) ?? const [])
|
||||||
if (x is Map) AiService.fromJson(x),
|
if (x is Map) AiService.fromJson(x),
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ class AndroidKeepAlive {
|
||||||
|
|
||||||
LiveSync? _live;
|
LiveSync? _live;
|
||||||
CopyCenter? _copies;
|
CopyCenter? _copies;
|
||||||
|
AiStore? _ai;
|
||||||
bool? _background;
|
bool? _background;
|
||||||
|
|
||||||
/// 진행률 알림은 자주 바뀌므로 1초에 한 번만 보낸다
|
/// 진행률 알림은 자주 바뀌므로 1초에 한 번만 보낸다
|
||||||
|
|
@ -64,14 +65,21 @@ class AndroidKeepAlive {
|
||||||
_copies = copies?..addListener(_changed);
|
_copies = copies?..addListener(_changed);
|
||||||
}
|
}
|
||||||
final copying = copies?.activeCount ?? 0;
|
final copying = copies?.activeCount ?? 0;
|
||||||
final ai = AiJobs.instance.statusLine;
|
// 149: AI 모델 받기도 화면을 끄거나 다른 앱으로 가도 계속 (진행 알림)
|
||||||
|
final store = AiStore.instance;
|
||||||
|
if (store != _ai) {
|
||||||
|
_ai?.removeListener(_changed);
|
||||||
|
_ai = store?..addListener(_changed);
|
||||||
|
}
|
||||||
|
final ai = [AiJobs.instance.statusLine, store?.statusLine].nonNulls.join(' · ').trim();
|
||||||
var (text, progress) =
|
var (text, progress) =
|
||||||
status(c.currentJob, c.busy, running, downloads.overallProgress,
|
status(c.currentJob, c.busy, running, downloads.overallProgress,
|
||||||
syncs: syncs, syncing: live?.anyRunning ?? false, copies: copying, stopped: live?.problems.length ?? 0);
|
syncs: syncs, syncing: live?.anyRunning ?? false, copies: copying, stopped: live?.problems.length ?? 0);
|
||||||
if (ai != null) {
|
if (ai.isNotEmpty) {
|
||||||
// 진행 알림에 남은 장 수와 예상 시간 (발열 · 배터리 때문에 쉬어 가지는 않는다 - 사용자 결정)
|
// 진행 알림에 남은 장 수와 예상 시간 (발열 · 배터리 때문에 쉬어 가지는 않는다 - 사용자 결정)
|
||||||
text = text == null ? ai : '$ai · $text';
|
text = text == null ? ai : '$ai · $text';
|
||||||
if (!c.busy && running == 0) progress = ((AiJobs.instance.progress ?? 0) * 100).round().clamp(0, 100);
|
final p = AiJobs.instance.progress ?? store?.progress.values.firstOrNull;
|
||||||
|
if (!c.busy && running == 0 && p != null) progress = (p * 100).round().clamp(0, 100);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (text == null) {
|
if (text == null) {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../app/ai_local.dart';
|
import '../app/ai_local.dart';
|
||||||
import '../app/app_controller.dart';
|
import '../app/app_controller.dart';
|
||||||
|
import '../app/copy_center.dart' show diskSpace;
|
||||||
import '../core/ai_catalog.dart';
|
import '../core/ai_catalog.dart';
|
||||||
import '../core/secret_gate.dart';
|
import '../core/secret_gate.dart';
|
||||||
import '../l10n/tr.dart';
|
import '../l10n/tr.dart';
|
||||||
|
|
@ -76,7 +77,8 @@ class _AiSettingsState extends State<AiSettings> {
|
||||||
|
|
||||||
/// 받기 전에 라이선스 조건 (있으면) 을 보여 주고 동의를 받는다
|
/// 받기 전에 라이선스 조건 (있으면) 을 보여 주고 동의를 받는다
|
||||||
Future<bool> _agree(AiFile f) async {
|
Future<bool> _agree(AiFile f) async {
|
||||||
if (f.terms == null) return true;
|
// 149-④: 한 번 동의했으면 다시 묻지 않는다
|
||||||
|
if (f.terms == null || c.settings.aiAgreed.contains(f.id)) return true;
|
||||||
final ok = await showDialog<bool>(
|
final ok = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
|
|
@ -96,19 +98,26 @@ class _AiSettingsState extends State<AiSettings> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (ok == true) await c.updateSettings((x) => x.aiAgreed = {...x.aiAgreed, f.id}.toList());
|
||||||
return ok == true;
|
return ok == true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 받기: 조건 동의를 먼저 모두 받은 뒤 차례로 (기다리는 것은 "차례 기다림")
|
||||||
Future<void> _install(List<AiFile> files) async {
|
Future<void> _install(List<AiFile> files) async {
|
||||||
|
final agreed = <AiFile>[];
|
||||||
for (final f in files) {
|
for (final f in files) {
|
||||||
if (store.isInstalled(f.id) || !await _agree(f)) continue;
|
if (store.isFileInstalled(f)) continue;
|
||||||
|
if (await _agree(f)) agreed.add(f);
|
||||||
|
}
|
||||||
|
if (agreed.isEmpty) return;
|
||||||
try {
|
try {
|
||||||
setState(() => _error = null);
|
setState(() => _error = null);
|
||||||
await store.install(f.id);
|
await store.installAll(agreed);
|
||||||
} catch (e) {
|
} on ImageAiException catch (e) {
|
||||||
if (mounted) setState(() => _error = '$e');
|
if (mounted) setState(() => _error = e.message);
|
||||||
return;
|
} catch (_) {
|
||||||
}
|
// 쉬운 말로만 (원문 · 주소는 보이지 않게)
|
||||||
|
if (mounted) setState(() => _error = tr('받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요'));
|
||||||
}
|
}
|
||||||
await _refresh();
|
await _refresh();
|
||||||
}
|
}
|
||||||
|
|
@ -132,6 +141,24 @@ class _AiSettingsState extends State<AiSettings> {
|
||||||
? null
|
? null
|
||||||
: FilledButton.tonal(onPressed: () => _install(missing), child: Text(tr('필요한 것 모두 받기'))),
|
: FilledButton.tonal(onPressed: () => _install(missing), child: Text(tr('필요한 것 모두 받기'))),
|
||||||
),
|
),
|
||||||
|
// 149-③: 받기 전에 필요한 공간과 지금 남은 공간을 늘 한 줄로
|
||||||
|
if (missing.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
|
||||||
|
child: FutureBuilder<(int, int)?>(
|
||||||
|
future: diskSpace(store.root),
|
||||||
|
builder: (context, snap) {
|
||||||
|
final need = missing.fold<int>(0, (a, f) => a + store.neededSpace(f));
|
||||||
|
final free = snap.data?.$1;
|
||||||
|
return Text(
|
||||||
|
free == null
|
||||||
|
? trf('필요한 공간 {0}', [AiStore.sizeText(need)])
|
||||||
|
: trf('필요한 공간 {0} · 남은 공간 {1}', [AiStore.sizeText(need), AiStore.sizeText(free)]),
|
||||||
|
style: TextStyle(fontSize: 12, color: free != null && free < need ? Colors.redAccent : JjColors.textDim),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
if (_error != null)
|
if (_error != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
|
@ -232,6 +259,9 @@ class _AiSettingsState extends State<AiSettings> {
|
||||||
final installed = store.isInstalled(f.id);
|
final installed = store.isInstalled(f.id);
|
||||||
final prog = store.progress[f.id];
|
final prog = store.progress[f.id];
|
||||||
final busy = store.progress.containsKey(f.id);
|
final busy = store.progress.containsKey(f.id);
|
||||||
|
final waiting = store.queued.contains(f.id);
|
||||||
|
// 받다가 끊겨 조각이 남아 있으면 [이어 받기]
|
||||||
|
final partial = !installed && !busy && store.neededSpace(f) < (f.kind == 'engine' ? f.size * 3 : f.size);
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(left: 16),
|
padding: const EdgeInsets.only(left: 16),
|
||||||
child: SettingTile(
|
child: SettingTile(
|
||||||
|
|
@ -239,10 +269,21 @@ class _AiSettingsState extends State<AiSettings> {
|
||||||
leading: Icon(installed ? Icons.check_circle : Icons.download_outlined,
|
leading: Icon(installed ? Icons.check_circle : Icons.download_outlined,
|
||||||
color: installed ? JjColors.success : JjColors.textDim, size: 20),
|
color: installed ? JjColors.success : JjColors.textDim, size: 20),
|
||||||
title: Text(tr(f.name)),
|
title: Text(tr(f.name)),
|
||||||
|
// 149-①②: 진행 막대 옆에 % · 받은 크기 / 전체 · 남은 시간, 기다리는 것은 "차례 기다림"
|
||||||
subtitle: busy
|
subtitle: busy
|
||||||
? LinearProgressIndicator(value: prog)
|
? Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
|
||||||
: Text('${AiStore.sizeText(f.size)} · ${f.license}${installed ? ' · ${tr('받음')}' : ''}'),
|
LinearProgressIndicator(value: prog),
|
||||||
trailing: busy
|
const SizedBox(height: 2),
|
||||||
|
Text(store.progressText(f.id) ?? '', style: const TextStyle(fontSize: 12)),
|
||||||
|
])
|
||||||
|
: Text([
|
||||||
|
AiStore.sizeText(f.size),
|
||||||
|
f.license,
|
||||||
|
if (installed) tr('받음'),
|
||||||
|
if (waiting) tr('차례 기다림'),
|
||||||
|
?store.failures[f.id],
|
||||||
|
].join(' · ')),
|
||||||
|
trailing: busy || waiting
|
||||||
? TextButton(onPressed: () => store.cancel(f.id), child: Text(tr('취소')))
|
? TextButton(onPressed: () => store.cancel(f.id), child: Text(tr('취소')))
|
||||||
: installed
|
: installed
|
||||||
? TextButton(
|
? TextButton(
|
||||||
|
|
@ -255,7 +296,7 @@ class _AiSettingsState extends State<AiSettings> {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Text(tr('지우기')))
|
child: Text(tr('지우기')))
|
||||||
: OutlinedButton(onPressed: () => _install([f]), child: Text(tr('받기'))),
|
: OutlinedButton(onPressed: () => _install([f]), child: Text(partial ? tr('이어 받기') : tr('받기'))),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,101 @@ void main() {
|
||||||
expect(tmp.listSync(recursive: true).whereType<File>().where((f) => f.path.contains('.part')), isEmpty);
|
expect(tmp.listSync(recursive: true).whereType<File>().where((f) => f.path.contains('.part')), isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('149: 끊기면 받은 곳부터 이어 받고 (Range), 서버가 이어 주지 않으면 처음부터 · 진행 글', () async {
|
||||||
|
final store = AiStore(tmp.path);
|
||||||
|
final body = List<int>.generate(300000, (i) => i * 7 % 251);
|
||||||
|
final f = file('big', '/big', body);
|
||||||
|
// 첫 요청은 120000 바이트만 보내고 연결을 끊는다, 그다음은 Range 로 나머지
|
||||||
|
final port = server.port;
|
||||||
|
await server.close(force: true);
|
||||||
|
final ranges = <String?>[];
|
||||||
|
server = await HttpServer.bind(InternetAddress.loopbackIPv4, port);
|
||||||
|
server.listen((req) async {
|
||||||
|
final range = req.headers.value(HttpHeaders.rangeHeader);
|
||||||
|
ranges.add(range);
|
||||||
|
if (range == null) {
|
||||||
|
final sock = await req.response.detachSocket(writeHeaders: false);
|
||||||
|
sock.add(utf8.encode('HTTP/1.1 200 OK\r\nContent-Length: ${body.length}\r\n\r\n'));
|
||||||
|
sock.add(body.sublist(0, 120000));
|
||||||
|
await sock.flush();
|
||||||
|
sock.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final from = int.parse(RegExp(r'bytes=(\d+)-').firstMatch(range)!.group(1)!);
|
||||||
|
req.response
|
||||||
|
..statusCode = 206
|
||||||
|
..headers.set(HttpHeaders.contentRangeHeader, 'bytes $from-${body.length - 1}/${body.length}')
|
||||||
|
..add(body.sublist(from));
|
||||||
|
await req.response.close();
|
||||||
|
});
|
||||||
|
final texts = <String>[];
|
||||||
|
void listen() {
|
||||||
|
final t = store.progressText('big');
|
||||||
|
if (t != null && t.isNotEmpty) texts.add(t);
|
||||||
|
}
|
||||||
|
store.addListener(listen);
|
||||||
|
await store.installFile(f, retryWait: const Duration(milliseconds: 10));
|
||||||
|
store.removeListener(listen);
|
||||||
|
expect(store.isFileInstalled(f), isTrue);
|
||||||
|
expect(File(store.pathOf(f)).readAsBytesSync(), body);
|
||||||
|
expect(ranges.first, isNull);
|
||||||
|
expect(ranges.last, 'bytes=120000-', reason: '받은 곳부터');
|
||||||
|
expect(texts.any((t) => t.contains('%') && t.contains(' / ')), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('149: 여러 번 끊기면 쉬운 말 (주소 없음) 로 알리고 조각은 남겨 [이어 받기] 로 이어 받음', () async {
|
||||||
|
final store = AiStore(tmp.path);
|
||||||
|
final body = List<int>.generate(50000, (i) => i % 256);
|
||||||
|
final f = file('drop', '/drop', body);
|
||||||
|
var calls = 0;
|
||||||
|
final port = server.port;
|
||||||
|
await server.close(force: true);
|
||||||
|
server = await HttpServer.bind(InternetAddress.loopbackIPv4, port);
|
||||||
|
var allow = false;
|
||||||
|
server.listen((req) async {
|
||||||
|
calls++;
|
||||||
|
final range = req.headers.value(HttpHeaders.rangeHeader);
|
||||||
|
final from = range == null ? 0 : int.parse(RegExp(r'bytes=(\d+)-').firstMatch(range)!.group(1)!);
|
||||||
|
if (!allow) {
|
||||||
|
final sock = await req.response.detachSocket(writeHeaders: false);
|
||||||
|
final n = (from + 5000).clamp(0, body.length);
|
||||||
|
sock.add(utf8.encode('HTTP/1.1 ${range == null ? '200 OK' : '206 Partial Content'}\r\nContent-Length: ${body.length - from}\r\n\r\n'));
|
||||||
|
sock.add(body.sublist(from, n));
|
||||||
|
await sock.flush();
|
||||||
|
sock.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.response
|
||||||
|
..statusCode = range == null ? 200 : 206
|
||||||
|
..add(body.sublist(from));
|
||||||
|
await req.response.close();
|
||||||
|
});
|
||||||
|
await expectLater(store.installFile(f, retries: 2, retryWait: const Duration(milliseconds: 10)),
|
||||||
|
throwsA(isA<ImageAiException>().having((e) => e.message, 'message', allOf(contains('이어 받기'), isNot(contains('http'))))));
|
||||||
|
expect(calls, 3);
|
||||||
|
expect(store.failures['drop'], contains('연결이 끊겼습니다'));
|
||||||
|
expect(store.neededSpace(f), lessThan(body.length), reason: '받은 조각을 남김');
|
||||||
|
allow = true;
|
||||||
|
await store.installFile(f, retryWait: const Duration(milliseconds: 10));
|
||||||
|
expect(File(store.pathOf(f)).readAsBytesSync(), body);
|
||||||
|
expect(store.failures, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('149: 한꺼번에 받기 - 기다리는 것은 "차례 기다림"', () async {
|
||||||
|
final store = AiStore(tmp.path);
|
||||||
|
final a = file('qa', '/qa', List.filled(1000, 1)), b2 = file('qb', '/qb', List.filled(1000, 2));
|
||||||
|
final seen = <String>{};
|
||||||
|
void listen() {
|
||||||
|
if (store.progress.containsKey('qa') && store.queued.contains('qb')) seen.add(store.progressText('qb')!);
|
||||||
|
}
|
||||||
|
store.addListener(listen);
|
||||||
|
await store.installAll([a, b2]);
|
||||||
|
store.removeListener(listen);
|
||||||
|
expect(seen, contains('차례 기다림'));
|
||||||
|
expect(store.isFileInstalled(a) && store.isFileInstalled(b2), isTrue);
|
||||||
|
expect(store.queued, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
test('실행 파일 zip 은 풀어 둔다', () async {
|
test('실행 파일 zip 은 풀어 둔다', () async {
|
||||||
final store = AiStore(tmp.path);
|
final store = AiStore(tmp.path);
|
||||||
final arc = Archive()..addFile(ArchiveFile.bytes('sd-cli.exe', utf8.encode('fake')));
|
final arc = Archive()..addFile(ArchiveFile.bytes('sd-cli.exe', utf8.encode('fake')));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue