diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 53eace8..7478a8d 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -123,6 +123,7 @@ "PC 전체 CPU · 메모리 사용량": "Whole-PC CPU · memory usage", "PC 전체 사용량\nCPU {0}%\n메모리 {1} / {2}GB ({3}%){4}": "Whole-PC usage\nCPU {0}%\nMemory {1} / {2}GB ({3}%){4}", "PDF 로 만들기": "Make PDF", + "PDF 로 바꾸는 중: {0}": "Converting to PDF: {0}", "PDF 를 만들지 못했습니다: {0}": "Could not make the PDF: {0}", "PDF 를 열 수 없습니다: {0}": "Cannot open the PDF: {0}", "PDF 에 넣을 그림 선택": "Choose images to add to the PDF", @@ -143,6 +144,7 @@ "Whisper small (정확)": "Whisper small (accurate)", "Windows 기본 브라우저": "Windows default browser", "Windows 기본 프로그램": "Windows default program", + "Windows 에서만 쓸 수 있습니다. 이 기기에서는 문서를 다른 앱으로 엽니다.": "Available on Windows only. On this device, documents open in another app.", "Windows 탐색기": "Windows Explorer", "YouTube · 토렌트 다운로드 목록": "YouTube · torrent download list", "YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp, magnet · .torrent → aria2 (no seeding after completion)", @@ -201,14 +203,17 @@ "{0} 번역 중": "Translating {0}", "{0} 번역 중 {1}%": "Translating {0} {1}%", "{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)": "Use the YouTube sign-in cookies of the {0} browser (Chrome · Edge may need to be closed to be read)", + "{0} 설치": "Install {0}", "{0} 순으로 정렬{1}": "Sort by {0}{1}", "{0} 아래에 만듭니다": "Created under {0}", "{0} 안의 파일": "Files in {0}", "{0} 에 붙여넣기 ({1}개)": "Paste into {0} ({1})", "{0} 에서 받을 파일을 찾지 못했습니다": "Could not find a file to download from {0}", "{0} 외 {1}개": "{0} and {1} more", + "{0} 을(를) 설치했습니다.": "Installed {0}.", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.", "{0} 전체": "All of {0}", + "{0} 제거": "Remove {0}", "{0} 추가": "Add {0}", "{0} 파일": "{0} file", "{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "Delete page {0}? (Saved to the file only when you save)", @@ -344,6 +349,7 @@ "내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "Interrupted model downloads (.part) · leftovers from conversion (.tmp)", "내려받은 Chrome 엔진 지우기": "Delete the downloaded Chrome engine", "내려받은 rsync 지우기": "Delete downloaded rsync", + "내려받은 변환기 파일을 지웁니다. 다시 켜면 다시 내려받습니다.": "Deletes the downloaded converter files. Turning it on again downloads them again.", "내보냈습니다: {0}": "Exported: {0}", "내역": "History", "내역 (최근 폴더)": "History (recent folders)", @@ -517,11 +523,13 @@ "목록 보기": "View list", "목록에서 제거": "Remove from list", "목록에서 지우기 (파일은 그대로)": "Remove from list (files stay)", + "목록을 받는 중": "Getting the list", "목록의 동영상 {0}개 모두": "All {0} videos in the list", "몽골어": "Mongolian", "무한": "Unlimited", "문서 미리보기": "Document preview", "문자셋 경고": "Charset warning", + "미리 볼 수 없습니다: {0}": "Cannot preview: {0}", "미리보기: 목록에서 동영상을 고르세요": "Preview: choose a video in the list", "미얀마어": "Burmese", "미지정": "Unspecified", @@ -574,6 +582,7 @@ "변경 내용을 저장하지 않고 나갈까요?": "Leave without saving the changes?", "변환": "Convert", "보관해 둔 설정을 되살렸습니다: {0}": "Restored the kept settings: {0}", + "보기": "View", "보기 {0}초 · Ctrl+휠 확대/축소": "View {0} s · Ctrl+wheel to zoom", "보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "Choose the buttons to show and drag to reorder.", "복사": "Copy", @@ -662,6 +671,7 @@ "설치하는 중": "Installing", "설치하면 필요한 파일을 내려받습니다 (준비 중)": "Installing downloads the files it needs (coming soon)", "설치하지 못했습니다.\n{0}": "Could not install.\n{0}", + "설치하지 못했습니다: {0}": "Could not install: {0}", "성공: {0}": "Succeeded: {0}", "세로 4:5": "Portrait 4:5", "세로 9:16": "Portrait 9:16", @@ -843,6 +853,7 @@ "위 폴더": "Parent folder", "위로": "Up", "위아래": "Top and bottom", + "위쪽 이동 버튼과 좌우로 밀기의 순서입니다. ▲ ▼ 로 바꿉니다.": "Order of the top navigation buttons and of swiping. Change it with ▲ ▼.", "위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.": "Order of the top navigation buttons and of swiping. Drag a row to change it.", "유형": "Type", "음성": "Audio", @@ -1142,6 +1153,7 @@ "칠한 시간이 없어 동기화하지 않습니다.": "No hours painted, so nothing will sync.", "컴포넌트": "Components", "켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "When on, double-tapping shows the images inside right away. When off, opens the file list (extract selected).", + "켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.": "Turning it on downloads and installs a converter (about 420MB). Turning it off deletes it.", "켜져 있는 창에서 (기본)": "In the open window (default)", "코덱": "Codec", "크기": "Size", @@ -1274,6 +1286,7 @@ "확인": "OK", "확인 · 압축 푸는 중…": "Verifying · extracting…", "확인 중…": "Checking…", + "확장 설치": "Installing extension", "확장자": "Ext", "확장자별 재생 프로그램": "Player per file extension", "환경 설정": "Settings", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 30cf123..40f1b9d 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -123,6 +123,7 @@ "PC 전체 CPU · 메모리 사용량": "PC 全体の CPU・メモリ使用量", "PC 전체 사용량\nCPU {0}%\n메모리 {1} / {2}GB ({3}%){4}": "PC 全体の使用量\nCPU {0}%\nメモリ {1} / {2}GB({3}%){4}", "PDF 로 만들기": "PDF を作成", + "PDF 로 바꾸는 중: {0}": "PDF に変換中: {0}", "PDF 를 만들지 못했습니다: {0}": "PDF を作成できませんでした: {0}", "PDF 를 열 수 없습니다: {0}": "PDF を開けません: {0}", "PDF 에 넣을 그림 선택": "PDF に追加する画像を選択", @@ -143,6 +144,7 @@ "Whisper small (정확)": "Whisper small(高精度)", "Windows 기본 브라우저": "Windows の既定のブラウザー", "Windows 기본 프로그램": "Windows の既定のプログラム", + "Windows 에서만 쓸 수 있습니다. 이 기기에서는 문서를 다른 앱으로 엽니다.": "Windows でのみ使えます。この端末では文書を他のアプリで開きます。", "Windows 탐색기": "Windows エクスプローラー", "YouTube · 토렌트 다운로드 목록": "YouTube・トレント ダウンロード一覧", "YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp、マグネット・.torrent → aria2(完了後のアップロードなし)", @@ -201,14 +203,17 @@ "{0} 번역 중": "{0} を翻訳中", "{0} 번역 중 {1}%": "{0} を翻訳中 {1}%", "{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)": "{0} ブラウザーの YouTube ログインクッキーを使う(Chrome・Edge はブラウザーを閉じないと読めないことがあります)", + "{0} 설치": "{0} のインストール", "{0} 순으로 정렬{1}": "{0} 順に並べ替え{1}", "{0} 아래에 만듭니다": "{0} の下に作成します", "{0} 안의 파일": "{0} 内のファイル", "{0} 에 붙여넣기 ({1}개)": "{0} に貼り付け ({1} 件)", "{0} 에서 받을 파일을 찾지 못했습니다": "{0} からダウンロードするファイルが見つかりませんでした", "{0} 외 {1}개": "{0} ほか {1} 件", + "{0} 을(를) 설치했습니다.": "{0} をインストールしました。", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。", "{0} 전체": "{0} 全体", + "{0} 제거": "{0} の削除", "{0} 추가": "{0} を追加", "{0} 파일": "{0} ファイル", "{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "{0} ページを削除しますか? (保存するとファイルに反映されます)", @@ -344,6 +349,7 @@ "내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "ダウンロードが途切れたモデル(.part)・変換の残り(.tmp)", "내려받은 Chrome 엔진 지우기": "ダウンロードした Chrome エンジンを削除", "내려받은 rsync 지우기": "ダウンロードした rsync を削除", + "내려받은 변환기 파일을 지웁니다. 다시 켜면 다시 내려받습니다.": "ダウンロードした変換ツールのファイルを削除します。再びオンにすると再ダウンロードします。", "내보냈습니다: {0}": "書き出しました: {0}", "내역": "履歴", "내역 (최근 폴더)": "履歴 (最近のフォルダー)", @@ -517,11 +523,13 @@ "목록 보기": "リストを見る", "목록에서 제거": "リストから削除", "목록에서 지우기 (파일은 그대로)": "リストから削除 (ファイルはそのまま)", + "목록을 받는 중": "一覧を取得中", "목록의 동영상 {0}개 모두": "リストの動画 {0}個すべて", "몽골어": "モンゴル語", "무한": "無制限", "문서 미리보기": "文書プレビュー", "문자셋 경고": "文字コードの警告", + "미리 볼 수 없습니다: {0}": "プレビューできません: {0}", "미리보기: 목록에서 동영상을 고르세요": "プレビュー: リストで動画を選んでください", "미얀마어": "ミャンマー語", "미지정": "未指定", @@ -574,6 +582,7 @@ "변경 내용을 저장하지 않고 나갈까요?": "変更を保存せずに終了しますか?", "변환": "変換", "보관해 둔 설정을 되살렸습니다: {0}": "保管した設定を復元しました: {0}", + "보기": "表示", "보기 {0}초 · Ctrl+휠 확대/축소": "表示 {0}秒・Ctrl+ホイールで拡大/縮小", "보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "表示するボタンを選び、ドラッグで順序を変えます。", "복사": "コピー", @@ -662,6 +671,7 @@ "설치하는 중": "インストール中", "설치하면 필요한 파일을 내려받습니다 (준비 중)": "インストールすると必要なファイルをダウンロードします (準備中)", "설치하지 못했습니다.\n{0}": "インストールできませんでした。\n{0}", + "설치하지 못했습니다: {0}": "インストールできませんでした: {0}", "성공: {0}": "成功: {0}", "세로 4:5": "縦 4:5", "세로 9:16": "縦 9:16", @@ -843,6 +853,7 @@ "위 폴더": "上のフォルダー", "위로": "上へ", "위아래": "上下", + "위쪽 이동 버튼과 좌우로 밀기의 순서입니다. ▲ ▼ 로 바꿉니다.": "上部の移動ボタンと左右スワイプの順番です。▲ ▼ で変更します。", "위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.": "上部の移動ボタンと左右スワイプの順番です。行をドラッグして変更します。", "유형": "種類", "음성": "音声", @@ -1142,6 +1153,7 @@ "칠한 시간이 없어 동기화하지 않습니다.": "塗った時間がないため同期しません。", "컴포넌트": "コンポーネント", "켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "オンにするとダブルタップで中の画像をすぐめくって表示します。オフにすると一覧 (選んで展開) を開きます。", + "켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.": "オンにすると変換ツールをダウンロードしてインストールします (約 420MB)。オフにすると削除します。", "켜져 있는 창에서 (기본)": "開いているウィンドウで(既定)", "코덱": "コーデック", "크기": "サイズ", @@ -1274,6 +1286,7 @@ "확인": "OK", "확인 · 압축 푸는 중…": "確認・展開中…", "확인 중…": "確認中…", + "확장 설치": "拡張機能のインストール", "확장자": "拡張子", "확장자별 재생 프로그램": "拡張子ごとの再生プログラム", "환경 설정": "環境設定", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 6390257..f4a1479 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -123,6 +123,7 @@ "PC 전체 CPU · 메모리 사용량": "整机 CPU · 内存使用率", "PC 전체 사용량\nCPU {0}%\n메모리 {1} / {2}GB ({3}%){4}": "整机使用率\nCPU {0}%\n内存 {1} / {2}GB({3}%){4}", "PDF 로 만들기": "制作 PDF", + "PDF 로 바꾸는 중: {0}": "正在转换为 PDF: {0}", "PDF 를 만들지 못했습니다: {0}": "无法制作 PDF: {0}", "PDF 를 열 수 없습니다: {0}": "无法打开 PDF: {0}", "PDF 에 넣을 그림 선택": "选择要加入 PDF 的图片", @@ -143,6 +144,7 @@ "Whisper small (정확)": "Whisper small(准确)", "Windows 기본 브라우저": "Windows 默认浏览器", "Windows 기본 프로그램": "Windows 默认程序", + "Windows 에서만 쓸 수 있습니다. 이 기기에서는 문서를 다른 앱으로 엽니다.": "仅限 Windows。在本设备上会用其他应用打开文档。", "Windows 탐색기": "Windows 资源管理器", "YouTube · 토렌트 다운로드 목록": "YouTube · 种子下载列表", "YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp,磁力链接 · .torrent → aria2(完成后不做种)", @@ -201,14 +203,17 @@ "{0} 번역 중": "正在翻译 {0}", "{0} 번역 중 {1}%": "正在翻译 {0} {1}%", "{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)": "使用 {0} 浏览器的 YouTube 登录 Cookie(Chrome · Edge 可能需要关闭浏览器才能读取)", + "{0} 설치": "安装 {0}", "{0} 순으로 정렬{1}": "按{0}排序{1}", "{0} 아래에 만듭니다": "在 {0} 下创建", "{0} 안의 파일": "{0} 中的文件", "{0} 에 붙여넣기 ({1}개)": "粘贴到 {0} ({1} 个)", "{0} 에서 받을 파일을 찾지 못했습니다": "未在 {0} 找到要下载的文件", "{0} 외 {1}개": "{0} 等 {1} 个", + "{0} 을(를) 설치했습니다.": "已安装 {0}。", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。", "{0} 전체": "{0} 全部", + "{0} 제거": "移除 {0}", "{0} 추가": "添加 {0}", "{0} 파일": "{0} 文件", "{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "删除第 {0} 页? (保存后才会写入文件)", @@ -344,6 +349,7 @@ "내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "下载中断的模型(.part)· 转换残留(.tmp)", "내려받은 Chrome 엔진 지우기": "删除已下载的 Chrome 引擎", "내려받은 rsync 지우기": "删除已下载的 rsync", + "내려받은 변환기 파일을 지웁니다. 다시 켜면 다시 내려받습니다.": "删除已下载的转换器文件。再次开启时会重新下载。", "내보냈습니다: {0}": "已导出: {0}", "내역": "历史", "내역 (최근 폴더)": "历史 (最近的文件夹)", @@ -517,11 +523,13 @@ "목록 보기": "查看列表", "목록에서 제거": "从列表中移除", "목록에서 지우기 (파일은 그대로)": "从列表中删除 (文件保留)", + "목록을 받는 중": "正在获取列表", "목록의 동영상 {0}개 모두": "列表中的全部 {0} 个视频", "몽골어": "蒙古语", "무한": "无限制", "문서 미리보기": "文档预览", "문자셋 경고": "字符集警告", + "미리 볼 수 없습니다: {0}": "无法预览: {0}", "미리보기: 목록에서 동영상을 고르세요": "预览: 请在列表中选择视频", "미얀마어": "缅甸语", "미지정": "未指定", @@ -574,6 +582,7 @@ "변경 내용을 저장하지 않고 나갈까요?": "不保存更改就离开吗?", "변환": "转换", "보관해 둔 설정을 되살렸습니다: {0}": "已恢复保存的设置: {0}", + "보기": "查看", "보기 {0}초 · Ctrl+휠 확대/축소": "显示 {0} 秒 · Ctrl+滚轮缩放", "보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "选择要显示的按钮,拖动以调整顺序。", "복사": "复制", @@ -662,6 +671,7 @@ "설치하는 중": "正在安装", "설치하면 필요한 파일을 내려받습니다 (준비 중)": "安装时会下载所需文件 (准备中)", "설치하지 못했습니다.\n{0}": "未能安装。\n{0}", + "설치하지 못했습니다: {0}": "无法安装: {0}", "성공: {0}": "成功: {0}", "세로 4:5": "竖屏 4:5", "세로 9:16": "竖屏 9:16", @@ -843,6 +853,7 @@ "위 폴더": "上级文件夹", "위로": "向上", "위아래": "上下", + "위쪽 이동 버튼과 좌우로 밀기의 순서입니다. ▲ ▼ 로 바꿉니다.": "顶部导航按钮和左右滑动的顺序。用 ▲ ▼ 更改。", "위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.": "顶部导航按钮和左右滑动的顺序。拖动行即可更改。", "유형": "类型", "음성": "音频", @@ -1142,6 +1153,7 @@ "칠한 시간이 없어 동기화하지 않습니다.": "没有涂色的时间,因此不会同步。", "컴포넌트": "组件", "켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "开启后双击会直接翻看其中的图片;关闭则打开文件列表 (选择解压)。", + "켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.": "开启时会下载并安装转换器 (约 420MB)。关闭时会删除。", "켜져 있는 창에서 (기본)": "在已打开的窗口中(默认)", "코덱": "编解码器", "크기": "大小", @@ -1274,6 +1286,7 @@ "확인": "确定", "확인 · 압축 푸는 중…": "正在校验 · 解压…", "확인 중…": "正在检查…", + "확장 설치": "安装扩展", "확장자": "扩展名", "확장자별 재생 프로그램": "按扩展名设置播放程序", "환경 설정": "设置", diff --git a/lib/app/component_store.dart b/lib/app/component_store.dart new file mode 100644 index 0000000..8e14a7c --- /dev/null +++ b/lib/app/component_store.dart @@ -0,0 +1,252 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; + +import '../core/app_update.dart' show updateRepo; + +/// 컴포넌트 목록 (GitHub 릴리스 "components" 의 components.json) 주소 +const componentsManifestUrl = 'https://github.com/$updateRepo/releases/download/components/components.json'; + +/// 컴포넌트를 설치할 때 받는 파일 하나 +class ComponentFile { + final String name; + final String url; + + /// 'msi' (Windows 설치 파일 → 관리 설치로 풀기) · 'zip' (풀기, 예: Java 런타임) · 'oxt' (LibreOffice 확장) · 'file' (그대로 둠) + final String kind; + final String? sha256; + final int size; + const ComponentFile({required this.name, required this.url, required this.kind, this.sha256, this.size = 0}); + + factory ComponentFile.fromJson(Map j) => ComponentFile( + name: j['name'] as String? ?? '', + url: j['url'] as String, + kind: j['kind'] as String? ?? 'file', + sha256: (j['sha256'] as String?)?.toLowerCase(), + size: (j['size'] as num?)?.toInt() ?? 0, + ); +} + +/// components.json: { "format": 1, "components": { "(id)": { "version": "...", "windows": [파일...], "android": [...] } } } +class ComponentManifest { + final Map> components; + const ComponentManifest(this.components); + + factory ComponentManifest.parse(String text) { + final j = jsonDecode(text) as Map; + final c = (j['components'] as Map? ?? const {}); + return ComponentManifest({for (final e in c.entries) e.key: e.value as Map}); + } + + /// 이 기기 ([platform]) 에서 그 컴포넌트를 설치할 때 받을 파일들 (없으면 null = 이 기기에서는 못 씀) + List? files(String id, String platform) { + final list = components[id]?[platform] as List?; + return list == null ? null : [for (final x in list) ComponentFile.fromJson(x as Map)]; + } + + String version(String id) => components[id]?['version'] as String? ?? ''; +} + +class ComponentException implements Exception { + final String message; + const ComponentException(this.message); + @override + String toString() => message; +} + +/// 컴포넌트 설치 (받기 · 확인 · 풀기) · 제거, 그리고 설치한 도구 쓰기 (문서 → PDF). +/// 앱 데이터 폴더의 components/(id) 아래에 둔다 (앱을 업데이트해도 지워지지 않게). +class ComponentStore { + final String dataDir; + final String manifestUrl; + final HttpClient _http = HttpClient()..userAgent = 'JJMKVMaker'; + bool _cancel = false; + + ComponentStore(this.dataDir, {this.manifestUrl = componentsManifestUrl}); + + static ComponentStore? _shared; + + /// 앱의 컴포넌트 저장소 (앱 데이터 폴더). 시험에서는 [ComponentStore.shared] 를 바꿔 쓴다. + static ComponentStore get shared => _shared ??= ComponentStore(_dataDir ?? Directory.systemTemp.path); + static set shared(ComponentStore s) => _shared = s; + + /// main 에서 정함 (앱 데이터 폴더) + static String? _dataDir; + static set dataDirectory(String d) => _dataDir = d; + + static String get platform => Platform.isWindows + ? 'windows' + : Platform.isAndroid + ? 'android' + : 'other'; + + Directory dirOf(String id) => Directory(p.join(dataDir, 'components', id)); + File _mark(String id) => File(p.join(dirOf(id).path, 'installed.json')); + + bool isInstalled(String id) => _mark(id).existsSync(); + + void cancel() => _cancel = true; + + Future manifest() async { + final req = await _http.getUrl(Uri.parse(manifestUrl)); + final res = await req.close(); + final body = await res.transform(utf8.decoder).join(); + if (res.statusCode != 200) throw ComponentException('컴포넌트 목록을 받을 수 없습니다 (${res.statusCode})'); + return ComponentManifest.parse(body); + } + + /// 설치: 목록의 파일을 받아 (SHA-256 확인) 풀고 설치 표시를 남긴다. + /// [onProgress] (지금 하는 일, 0~1 또는 모르면 null) + Future install(String id, {void Function(String step, double? progress)? onProgress}) async { + _cancel = false; + final m = await manifest(); + final files = m.files(id, platform); + if (files == null) throw const ComponentException('이 기기에서는 쓸 수 없는 컴포넌트입니다'); + final dir = dirOf(id); + final dl = Directory(p.join(dir.path, '_download')); + await dl.create(recursive: true); + try { + final got = <(ComponentFile, File)>[]; + for (final f in files) { + final out = File(p.join(dl.path, f.name.isEmpty ? p.basename(Uri.parse(f.url).path) : f.name)); + await _download(f, out, (d) => onProgress?.call('받는 중: ${f.name}', d)); + got.add((f, out)); + } + for (final (f, file) in got) { + if (_cancel) throw const ComponentException('취소했습니다'); + switch (f.kind) { + case 'msi': + onProgress?.call('푸는 중: ${f.name}', null); + await _runMsiAdmin(file.path, p.join(dir.path, 'app')); + case 'zip': + onProgress?.call('푸는 중: ${f.name}', null); + await extractFileToDisk(file.path, p.join(dir.path, 'jre')); + case 'oxt': + onProgress?.call('확장 설치: ${f.name}', null); + await _addExtension(id, file.path); + default: + await file.copy(p.join(dir.path, p.basename(file.path))); + } + } + await _mark(id).writeAsString(jsonEncode({'version': m.version(id), 'installed': DateTime.now().toIso8601String()})); + } finally { + try { + await dl.delete(recursive: true); + } catch (_) {} + } + } + + Future remove(String id) async { + final dir = dirOf(id); + if (await dir.exists()) await dir.delete(recursive: true); + } + + Future _download(ComponentFile f, File out, void Function(double? progress) onProgress) async { + final req = await _http.getUrl(Uri.parse(f.url)); + final res = await req.close(); + if (res.statusCode != 200) throw ComponentException('받을 수 없습니다 (${res.statusCode}): ${f.url}'); + final total = res.contentLength > 0 ? res.contentLength : f.size; + final sink = out.openWrite(); + final hash = AccumulatorSink(); + final hasher = sha256.startChunkedConversion(hash); + var done = 0; + try { + await for (final chunk in res) { + if (_cancel) throw const ComponentException('취소했습니다'); + sink.add(chunk); + hasher.add(chunk); + done += chunk.length; + onProgress(total > 0 ? done / total : null); + } + } finally { + await sink.close(); + hasher.close(); + } + final sum = hash.events.single.toString(); + if (f.sha256 != null && f.sha256!.isNotEmpty && sum != f.sha256) { + await out.delete(); + throw ComponentException('파일이 손상되었습니다 (SHA-256 다름): ${f.name}'); + } + } + + /// Windows 설치 파일 (MSI) 을 관리 설치 (/a) 로 풀기: 관리자 권한 없이 폴더에 프로그램 파일만 + Future _runMsiAdmin(String msi, String target) async { + await Directory(target).create(recursive: true); + final r = await Process.run('msiexec', ['/a', msi, '/qn', 'TARGETDIR=$target']); + if (r.exitCode != 0) throw ComponentException('설치 파일을 풀 수 없습니다 (msiexec ${r.exitCode})'); + } + + // ───────── 문서 미리보기 (LibreOffice) ───────── + + /// 설치한 LibreOffice 의 soffice.exe (없으면 null) + String? soffice([String id = 'docs']) { + final root = Directory(p.join(dirOf(id).path, 'app')); + if (!root.existsSync()) return null; + for (final e in root.listSync(recursive: true, followLinks: false)) { + if (e is File && p.basename(e.path).toLowerCase() == 'soffice.exe' && p.basename(p.dirname(e.path)) == 'program') { + return e.path; + } + } + return null; + } + + /// 함께 받은 Java 런타임 (HWP 확장 H2Orestart 가 Java 로 동작) 의 폴더 (bin/java.exe 가 있는 곳) + String? javaHome([String id = 'docs']) { + final root = Directory(p.join(dirOf(id).path, 'jre')); + if (!root.existsSync()) return null; + for (final e in root.listSync(recursive: true, followLinks: false)) { + if (e is File && p.basename(e.path).toLowerCase() == 'java.exe' && p.basename(p.dirname(e.path)) == 'bin') { + return p.dirname(p.dirname(e.path)); + } + } + return null; + } + + /// LibreOffice 가 쓸 환경: 받은 Java (JAVA_HOME) + Map? _env(String id) { + final jh = javaHome(id); + return jh == null ? null : {'JAVA_HOME': jh, 'PATH': '${p.join(jh, 'bin')};${Platform.environment['PATH'] ?? ''}'}; + } + + /// LibreOffice 사용자 설정 폴더 (앱 전용, 확장도 여기에) + String _profileUrl(String id) => Uri.directory(p.join(dirOf(id).path, 'profile')).toString(); + + Future _addExtension(String id, String oxt) async { + final office = soffice(id); + if (office == null) throw const ComponentException('LibreOffice 를 먼저 설치해야 합니다'); + final unopkg = p.join(p.dirname(office), 'unopkg.com'); + final r = await Process.run(unopkg, ['add', '--suppress-license', '-env:UserInstallation=${_profileUrl(id)}', oxt], + environment: _env(id)); + if (r.exitCode != 0) throw ComponentException('확장을 설치할 수 없습니다 (${r.exitCode}): ${r.stderr}'); + } + + /// 문서를 PDF 로 ([outDir] 에 만든 PDF 경로) + Future convertToPdf(String input, String outDir, {String id = 'docs'}) async { + final office = soffice(id); + if (office == null) throw const ComponentException('문서 미리보기 컴포넌트를 먼저 설치하세요'); + await Directory(outDir).create(recursive: true); + final r = await Process.run(office, [ + '--headless', '--norestore', '--nologo', '--nodefault', + '-env:UserInstallation=${_profileUrl(id)}', + '--convert-to', 'pdf', '--outdir', outDir, input, + ], environment: _env(id)).timeout(const Duration(minutes: 3)); + final out = p.join(outDir, '${p.basenameWithoutExtension(input)}.pdf'); + if (!File(out).existsSync()) { + throw ComponentException('PDF 로 바꾸지 못했습니다 (${r.exitCode}) ${'${r.stderr}'.trim()}'); + } + return out; + } +} + +/// sha256 의 startChunkedConversion 결과를 받는 그릇 +class AccumulatorSink implements Sink { + final events = []; + @override + void add(T event) => events.add(event); + @override + void close() {} +} diff --git a/lib/app/components.dart b/lib/app/components.dart index 76cad0f..acc8b3e 100644 --- a/lib/app/components.dart +++ b/lib/app/components.dart @@ -34,6 +34,9 @@ class AppComponent { static const all = [mkv, browser, explorer, rsync, downloads, viewer, docs]; + /// 문서 미리보기 대상 (PDF 로 바꿔 봄) + static const docExtensions = ['doc', 'docx', 'hwp', 'hwpx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'rtf']; + /// 화면 있는 것의 기본 순서 (좌우로 밀기 · 위쪽 버튼) static const defaultOrder = ['mkv', 'browser', 'explorer', 'rsync', 'downloads']; diff --git a/lib/core/app_update.dart b/lib/core/app_update.dart index 2955303..aee8059 100644 --- a/lib/core/app_update.dart +++ b/lib/core/app_update.dart @@ -81,7 +81,8 @@ List parseReleaseList(List list, {String assetPattern = wi final out = [ for (final j in list) if (j is Map) parseRelease(j, assetPattern: assetPattern, allowPrerelease: true), - ].whereType().toList(); + // 이 기기용 설치 파일이 있는 버전 릴리스만 (컴포넌트 목록 릴리스 "components" 등은 빼고) + ].whereType().where((r) => r.zipUrl != null && RegExp(r'^v?\d').hasMatch(r.tag)).toList(); out.sort((a, b) => compareVersions(b.version, a.version)); return out; } diff --git a/lib/main.dart b/lib/main.dart index bf0b82f..638e074 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'package:window_manager/window_manager.dart'; import 'app/app_controller.dart'; import 'app/live_sync.dart'; import 'app/version_snapshot.dart'; +import 'app/component_store.dart'; import 'app/bookmarks_controller.dart'; import 'app/download_manager.dart'; import 'app/settings.dart'; @@ -95,6 +96,7 @@ Future main(List args) async { } // 버전별 설정 보관 (업데이트 · 예전 버전으로 되돌리기): 시작할 때 설정 파일이 없었는지 (새로 설치) final freshInstall = !File(p.join(dataDir, 'settings.json')).existsSync(); + ComponentStore.dataDirectory = dataDir; VersionSnapshot.instance = VersionSnapshot(dataDir); await controller.init(); // 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함 @@ -396,6 +398,7 @@ Future runAndroid(String dataDir) async { controller.note(trf('── 시작 {0} (Android) ──', [appTitle])); // 버전별 설정 보관: 앱을 지웠다 다시 설치해도 남도록 공용 Download/JJ_MKVMaker 에도 (예전 버전으로 되돌릴 때) final freshInstall = !File(p.join(dataDir, 'settings.json')).existsSync(); + ComponentStore.dataDirectory = dataDir; String? sharedSnapshots; try { final root = await const MethodChannel('jj_mkvmaker/android').invokeMethod('storageRoot'); diff --git a/lib/ui/app_actions.dart b/lib/ui/app_actions.dart index 553de4b..72c4380 100644 --- a/lib/ui/app_actions.dart +++ b/lib/ui/app_actions.dart @@ -241,7 +241,10 @@ class AppNavButtons extends StatelessWidget { class SwipeNav extends StatefulWidget { final String current; final Widget child; - const SwipeNav({super.key, required this.current, required this.child}); + + /// 높이 어디서 시작해도 (위쪽 막대처럼 작은 곳에 씌울 때) + final bool anywhere; + const SwipeNav({super.key, required this.current, required this.child, this.anywhere = false}); @override State createState() => _SwipeNavState(); @@ -258,7 +261,7 @@ class _SwipeNavState extends State { behavior: HitTestBehavior.translucent, onHorizontalDragStart: (d) { final h = context.size?.height ?? 0; - _middle = h > 0 && d.localPosition.dy > h * 0.2 && d.localPosition.dy < h * 0.8; + _middle = widget.anywhere || (h > 0 && d.localPosition.dy > h * 0.2 && d.localPosition.dy < h * 0.8); }, onHorizontalDragEnd: (d) { final v = d.primaryVelocity ?? 0; diff --git a/lib/ui/browser_page.dart b/lib/ui/browser_page.dart index 18eef9e..2ebbde5 100644 --- a/lib/ui/browser_page.dart +++ b/lib/ui/browser_page.dart @@ -432,10 +432,7 @@ class _BrowserPageState extends State with RouteAware { child: Focus( autofocus: true, child: Scaffold( - // 웹 페이지 위에서의 밀기는 웹 페이지가 받으므로, 위쪽 막대 · 즐겨찾기 줄에서 좌우로 밀면 화면 이동 - body: SwipeNav( - current: 'browser', - child: ListenableBuilder( + body: ListenableBuilder( listenable: Listenable.merge([bm, widget.c]), builder: (context, _) => Column(children: [ _toolbar(), @@ -505,14 +502,16 @@ class _BrowserPageState extends State with RouteAware { ])), ]), ), - ), ), ), ), ); } - Widget _toolbar() { + /// 위쪽 막대: 여기서 좌우로 밀면 화면 이동 (웹 페이지 위에서는 웹 페이지의 스크롤과 다투어 위 · 아래가 이상했음) + Widget _toolbar() => SwipeNav(current: 'browser', anywhere: true, child: _toolbarBar()); + + Widget _toolbarBar() { final starred = bm.tree.findByUrl(_url) != null; IconButton btn(IconData i, String tip, VoidCallback? f, {Color? color}) => IconButton( tooltip: tip, diff --git a/lib/ui/component_settings.dart b/lib/ui/component_settings.dart index 6cc1a48..9d971dc 100644 --- a/lib/ui/component_settings.dart +++ b/lib/ui/component_settings.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'dart:io'; + import '../app/app_controller.dart'; +import '../app/component_store.dart'; import '../app/components.dart'; import '../l10n/tr.dart'; import 'setting_tile.dart'; @@ -48,6 +51,82 @@ class ViewerSettings extends StatelessWidget { } } +/// 내려받아 설치하는 컴포넌트 켜기 (진행 창 · 취소) · 끄기 (지우기) +Future _setDownloaded(BuildContext context, AppComponent x, bool on) async { + final c = (context.findAncestorWidgetOfExactType())!.c; + final store = ComponentStore.shared; + if (!on) { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + title: Text(trf('{0} 제거', [tr(x.name)])), + content: Text(tr('내려받은 변환기 파일을 지웁니다. 다시 켜면 다시 내려받습니다.')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))), + ], + ), + ); + if (ok != true) return; + await store.remove(x.id); + await c.updateSettings((v) => v.components = [for (final id in v.components) if (id != x.id) id]); + return; + } + final step = ValueNotifier<(String, double?)>((tr('목록을 받는 중'), null)); + Object? error; + var done = false; + if (!context.mounted) return; + final dialog = showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + scrollable: true, + title: Text(trf('{0} 설치', [tr(x.name)])), + content: ValueListenableBuilder<(String, double?)>( + valueListenable: step, + builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(v.$1), + const SizedBox(height: 10), + LinearProgressIndicator(value: v.$2), + ]), + ), + actions: [ + TextButton( + onPressed: () { + store.cancel(); + }, + child: Text(tr('취소')), + ), + ], + ), + ); + try { + // 단계 글 ("받는 중: 파일") 의 앞부분만 번역 + String trStep(String s) { + for (final k in ['받는 중', '푸는 중', '확장 설치']) { + if (s.startsWith('$k:')) return '${tr(k)}:${s.substring(k.length + 1)}'; + } + return s; + } + + await store.install(x.id, onProgress: (s, d) => step.value = (trStep(s), d)); + done = true; + } catch (e) { + error = e; + } + if (context.mounted) Navigator.of(context).pop(); + await dialog; + step.dispose(); + if (done) { + await c.updateSettings((v) => v.components = [for (final id in v.components) if (id != x.id) id, x.id]); + } + if (context.mounted) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar( + content: Text(done ? trf('{0} 을(를) 설치했습니다.', [tr(x.name)]) : trf('설치하지 못했습니다: {0}', [error])))); + } +} + /// 환경 설정 > 컴포넌트: 기능 묶음 설치 (켜기) · 제거 (끄기), 화면 순서 (끌어서 바꿈), 좌우로 밀어 화면 이동. /// 화면이 하나만 남으면 위쪽 이동 버튼 줄에는 그것만 보인다. class ComponentSettings extends StatelessWidget { @@ -61,6 +140,7 @@ class ComponentSettings extends StatelessWidget { final pages = AppComponent.pages(installed, s.navOrder); Future setInstalled(AppComponent x, bool on) async { + if (x.needsDownload) return _setDownloaded(context, x, on); // 화면 있는 것이 하나도 남지 않게는 못 한다 if (!on && x.page && pages.length <= 1 && pages.first.id == x.id) { ScaffoldMessenger.maybeOf(context) @@ -73,6 +153,13 @@ class ComponentSettings extends StatelessWidget { ]); } + // [from] 번째 화면을 [to] 자리로 (설치하지 않은 화면은 원래 순서대로 뒤에) + void move(int from, int to) { + final ids = [for (final p in pages) p.id]; + ids.insert(to, ids.removeAt(from)); + c.updateSettings((v) => v.navOrder = [...ids, ...AppComponent.defaultOrder.where((x) => !ids.contains(x))]); + } + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SwitchListTile( value: s.swipeNav, @@ -84,42 +171,39 @@ class ComponentSettings extends StatelessWidget { SettingTile( leading: Icon(x.icon, color: installed.contains(x.id) ? JjColors.accent : JjColors.textDim), title: Text(tr(x.name)), - subtitle: Text(x.needsDownload ? '${tr(x.description)}\n${tr('설치하면 필요한 파일을 내려받습니다 (준비 중)')}' : tr(x.description)), + subtitle: Text(x.needsDownload + ? '${tr(x.description)}\n${Platform.isWindows ? tr('켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.') : tr('Windows 에서만 쓸 수 있습니다. 이 기기에서는 문서를 다른 앱으로 엽니다.')}' + : tr(x.description)), trailing: Switch( value: installed.contains(x.id), - onChanged: x.needsDownload && !installed.contains(x.id) ? null : (v) => setInstalled(x, v), + onChanged: x.needsDownload && !Platform.isWindows ? null : (v) => setInstalled(x, v), ), ), const Divider(), SettingTile( leading: const Icon(Icons.swap_vert), title: Text(tr('화면 순서')), - subtitle: Text(tr('위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.')), + subtitle: Text(tr('위쪽 이동 버튼과 좌우로 밀기의 순서입니다. ▲ ▼ 로 바꿉니다.')), ), - ReorderableListView( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - buildDefaultDragHandles: false, - onReorderItem: (from, to) { - final ids = [for (final p in pages) p.id]; - ids.insert(to, ids.removeAt(from)); - // 설치하지 않은 화면은 원래 자리 순서대로 뒤에 - c.updateSettings((v) => v.navOrder = [...ids, ...AppComponent.defaultOrder.where((x) => !ids.contains(x))]); - }, - children: [ - for (final (i, p) in pages.indexed) - ReorderableDragStartListener( - key: ValueKey(p.id), - index: i, - child: ListTile( - dense: true, - leading: Text('${i + 1}', style: const TextStyle(color: JjColors.textDim)), - title: Row(children: [Icon(p.icon, size: 18), const SizedBox(width: 10), Text(tr(p.name))]), - trailing: const Icon(Icons.drag_handle), - ), + for (final (i, p) in pages.indexed) + ListTile( + key: ValueKey(p.id), + dense: true, + leading: Text('${i + 1}', style: const TextStyle(color: JjColors.textDim)), + title: Row(children: [Icon(p.icon, size: 18), const SizedBox(width: 10), Flexible(child: Text(tr(p.name)))]), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + IconButton( + tooltip: tr('위로'), + icon: const Icon(Icons.arrow_upward, size: 18), + onPressed: i == 0 ? null : () => move(i, i - 1), ), - ], - ), + IconButton( + tooltip: tr('아래로'), + icon: const Icon(Icons.arrow_downward, size: 18), + onPressed: i == pages.length - 1 ? null : () => move(i, i + 1), + ), + ]), + ), ]); } } diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index c33fa16..6aaf8bb 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -27,6 +27,8 @@ import 'rsync_setup.dart'; import 'theme.dart'; import 'webdav_settings.dart'; import 'reader_page.dart'; +import '../app/component_store.dart'; +import '../app/components.dart'; import 'zip_page.dart'; import '../core/reader_sources.dart'; import '../l10n/tr.dart'; @@ -548,6 +550,11 @@ class _ExplorerPageState extends State { if (ext == 'pdf') return _viewPdf(path); if (zipExtensions.contains(ext)) return _openZip(path, pane); } + // 문서 미리보기 (Windows: 설치한 변환기로 PDF 로 바꿔 봄) + if (c.settings.components.contains('docs') && Platform.isWindows && + AppComponent.docExtensions.contains(extOf(path))) { + return _viewDoc(path); + } final local = await _fetch(path); if (local == null) return; final ok = await c.services.shell.openWith(local); @@ -623,6 +630,46 @@ class _ExplorerPageState extends State { await openReader(context, c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), start: start); } + /// 문서 → PDF (진행 창) → 보기 + Future _viewDoc(String path) async { + final temp = await c.services.storage.tempDirectory(); + if (!mounted) return; + final dialog = showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + content: Row(children: [ + const CircularProgressIndicator(), + const SizedBox(width: 16), + Expanded(child: Text(trf('PDF 로 바꾸는 중: {0}', [vBasename(path)]))), + ]), + ), + ); + String? pdf; + Object? error; + try { + final local = await vLocalCopy(path, temp); + pdf = await ComponentStore.shared.convertToPdf(local, p.join(temp, 'jj_docs')); + } catch (e) { + error = e; + } + if (mounted) Navigator.of(context).pop(); + await dialog; + if (pdf == null) { + _snack(trf('미리 볼 수 없습니다: {0}', [error])); + return; + } + PdfSource src; + try { + src = await PdfSource.open(pdf, tempDir: temp); + } catch (e) { + _snack(trf('PDF 를 열 수 없습니다: {0}', [e])); + return; + } + if (!mounted) return; + await openReader(context, c, src); + } + Future _viewPdf(String path) async { final temp = await c.services.storage.tempDirectory(); PdfSource src; diff --git a/test/component_store_test.dart b/test/component_store_test.dart new file mode 100644 index 0000000..f18f39d --- /dev/null +++ b/test/component_store_test.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/component_store.dart'; +import 'package:path/path.dart' as p; + +/// 실제 설치 (LibreOffice 373MB 받기 · 풀기 · H2Orestart · 문서 → PDF): JJ_COMPONENTS_URL=목록 주소 일 때만. +/// JJ_COMPONENTS_DIR 를 주면 그 폴더에 설치하고 남긴다 (여러 번 시험할 때 다시 받지 않게). +void main() { + test('components.json 읽기: 기기별 파일 · 없는 기기는 null', () { + final m = ComponentManifest.parse(''' +{"format":1,"components":{"docs":{"version":"x","windows":[{"name":"a.msi","kind":"msi","url":"https://e/a.msi","sha256":"AB"}]}}}'''); + expect(m.version('docs'), 'x'); + final f = m.files('docs', 'windows')!.single; + expect([f.name, f.kind, f.sha256], ['a.msi', 'msi', 'ab']); + expect(m.files('docs', 'android'), isNull); + expect(m.files('nope', 'windows'), isNull); + }); + + final url = Platform.environment['JJ_COMPONENTS_URL']; + test('실제 설치: LibreOffice + H2Orestart → DOCX · HWPX 를 PDF 로', () async { + final keep = Platform.environment['JJ_COMPONENTS_DIR']; + final data = keep ?? Directory.systemTemp.createTempSync('jj_comp_').path; + final store = ComponentStore(data, manifestUrl: url!); + if (!store.isInstalled('docs')) { + var last = ''; + await store.install('docs', onProgress: (step, d) { + final s = '$step ${d == null ? '' : (d * 100).floor()}'; + if (s != last && (d == null || (d * 100).floor() % 10 == 0)) debugPrint(s); + last = s; + }); + } + expect(store.isInstalled('docs'), isTrue); + expect(store.soffice(), isNotNull); + // 시험 문서: LibreOffice 로 텍스트 → DOCX 를 만든 뒤 다시 PDF 로 + final tmp = Directory.systemTemp.createTempSync('jj_doc_'); + final txt = File(p.join(tmp.path, 'hello.txt'))..writeAsStringSync('안녕하세요 JJ 문서 미리보기'); + final pdf = await store.convertToPdf(txt.path, p.join(tmp.path, 'out')); + expect(File(pdf).lengthSync(), greaterThan(1000)); + final hwp = Platform.environment['JJ_HWP_FILE']; + if (hwp != null) { + final out = await store.convertToPdf(hwp, p.join(tmp.path, 'out')); + expect(File(out).lengthSync(), greaterThan(1000)); + } + tmp.deleteSync(recursive: true); + if (keep == null) await store.remove('docs'); + }, skip: url == null ? 'JJ_COMPONENTS_URL 일 때만' : null, timeout: const Timeout(Duration(minutes: 30))); +} diff --git a/test/components_test.dart b/test/components_test.dart index 5077c6f..52b9490 100644 --- a/test/components_test.dart +++ b/test/components_test.dart @@ -7,6 +7,7 @@ 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/app_actions.dart'; +import 'package:jj_mkvmaker/ui/component_settings.dart'; import 'package:jj_mkvmaker/ui/home_page.dart'; AppController _plain() => @@ -54,6 +55,30 @@ void main() { expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget); }); + testWidgets('화면 순서: ▲ ▼ 로 바꾸고 위쪽 버튼 줄도 그 순서', (tester) async { + tester.view.physicalSize = const Size(1200, 2400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final c = _plain(); + await tester.pumpWidget(MaterialApp( + builder: (context, child) => AppScope(controller: c, child: child!), + home: Scaffold(body: SingleChildScrollView(child: ListenableBuilder( + listenable: c, builder: (_, _) => ComponentSettings(c: c)))), + )); + // 2번째 (웹 브라우저) 를 위로 → 맨 앞 + await tester.tap(find.byTooltip('위로').at(1)); + await tester.pump(); + expect(c.settings.navOrder.take(2), ['browser', 'mkv']); + // 맨 앞 (웹 브라우저) 를 아래로 → 다시 2번째 + await tester.tap(find.byTooltip('아래로').first); + await tester.pump(); + expect(c.settings.navOrder.take(2), ['mkv', 'browser']); + // 마지막 (다운로드) 를 위로 → 4번째 + await tester.tap(find.byTooltip('위로').last); + await tester.pump(); + expect(c.settings.navOrder, ['mkv', 'browser', 'explorer', 'downloads', 'rsync']); + }); + testWidgets('화면 가운데를 좌우로 밀면 순서대로 다음 · 이전 화면 (끝 다음은 처음)', (tester) async { tester.view.physicalSize = const Size(1500, 900); tester.view.devicePixelRatio = 1;