문서 미리보기 컴포넌트 · 컴포넌트 설치 · 브라우저 위아래 · 화면 순서 버튼

- ComponentStore: GitHub 릴리스 "components" 의 components.json 을 읽어 설치 (받기 · SHA-256 확인 · 풀기) · 제거.
  앱 데이터 폴더 components/(id) 에 둔다 (앱 업데이트에 지워지지 않게).
- 문서 미리보기 (Windows): LibreOffice 26.2.6 (MSI 관리 설치, 관리자 권한 없이) + Temurin JRE 21 + H2Orestart
  (HWP · HWPX). DOC · DOCX · HWP · HWPX · XLS · XLSX · PPT · PPTX · ODT 등을 PDF 로 바꿔 보기.
  Android 는 다른 앱으로 연다.
- 환경 설정 > 컴포넌트: 문서 미리보기 켜면 진행 창과 함께 설치, 끄면 지움.
- 버전 목록: 이 기기용 설치 파일이 있는 버전 릴리스만 (components 릴리스 제외).
- 웹 브라우저: 좌우로 밀기는 위쪽 막대에서만 (웹 페이지의 위 · 아래 스크롤과 다투던 것).
- 화면 순서: 끌어 놓기 대신 ▲ ▼ (화면 배율 때문에 놓는 자리가 어긋나던 것).
- 시험: component_store_test (JJ_COMPONENTS_URL 일 때 실제 설치 · HWP 변환), components_test 순서 버튼.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-08 16:18:50 +09:00
parent ac8fd8ad9a
commit 70e2b87c11
13 changed files with 540 additions and 35 deletions

View File

@ -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",

View File

@ -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",
"확인 · 압축 푸는 중…": "確認・展開中…",
"확인 중…": "確認中…",
"확장 설치": "拡張機能のインストール",
"확장자": "拡張子",
"확장자별 재생 프로그램": "拡張子ごとの再生プログラム",
"환경 설정": "環境設定",

View File

@ -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 @@
"확인": "确定",
"확인 · 압축 푸는 중…": "正在校验 · 解压…",
"확인 중…": "正在检查…",
"확장 설치": "安装扩展",
"확장자": "扩展名",
"확장자별 재생 프로그램": "按扩展名设置播放程序",
"환경 설정": "设置",

View File

@ -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<String, dynamic> 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<String, Map<String, dynamic>> components;
const ComponentManifest(this.components);
factory ComponentManifest.parse(String text) {
final j = jsonDecode(text) as Map<String, dynamic>;
final c = (j['components'] as Map<String, dynamic>? ?? const {});
return ComponentManifest({for (final e in c.entries) e.key: e.value as Map<String, dynamic>});
}
/// 이 기기 ([platform]) 에서 그 컴포넌트를 설치할 때 받을 파일들 (없으면 null = 이 기기에서는 못 씀)
List<ComponentFile>? 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, dynamic>)];
}
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<ComponentManifest> 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<void> 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<void> remove(String id) async {
final dir = dirOf(id);
if (await dir.exists()) await dir.delete(recursive: true);
}
Future<void> _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<Digest>();
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<void> _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<String, String>? _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<void> _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<String> 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<T> implements Sink<T> {
final events = <T>[];
@override
void add(T event) => events.add(event);
@override
void close() {}
}

View File

@ -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'];

View File

@ -81,7 +81,8 @@ List<ReleaseInfo> parseReleaseList(List<dynamic> list, {String assetPattern = wi
final out = [
for (final j in list)
if (j is Map<String, dynamic>) parseRelease(j, assetPattern: assetPattern, allowPrerelease: true),
].whereType<ReleaseInfo>().toList();
// 이 기기용 설치 파일이 있는 버전 릴리스만 (컴포넌트 목록 릴리스 "components" 등은 빼고)
].whereType<ReleaseInfo>().where((r) => r.zipUrl != null && RegExp(r'^v?\d').hasMatch(r.tag)).toList();
out.sort((a, b) => compareVersions(b.version, a.version));
return out;
}

View File

@ -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<void> main(List<String> 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<void> 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<String>('storageRoot');

View File

@ -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<SwipeNav> createState() => _SwipeNavState();
@ -258,7 +261,7 @@ class _SwipeNavState extends State<SwipeNav> {
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;

View File

@ -432,10 +432,7 @@ class _BrowserPageState extends State<BrowserPage> 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<BrowserPage> 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,

View File

@ -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<void> _setDownloaded(BuildContext context, AppComponent x, bool on) async {
final c = (context.findAncestorWidgetOfExactType<ComponentSettings>())!.c;
final store = ComponentStore.shared;
if (!on) {
final ok = await showDialog<bool>(
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<void>(
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<void> 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),
),
]),
),
]);
}
}

View File

@ -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<ExplorerPage> {
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<ExplorerPage> {
await openReader(context, c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), start: start);
}
/// 문서 → PDF (진행 창) → 보기
Future<void> _viewDoc(String path) async {
final temp = await c.services.storage.tempDirectory();
if (!mounted) return;
final dialog = showDialog<void>(
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<void> _viewPdf(String path) async {
final temp = await c.services.storage.tempDirectory();
PdfSource src;

View File

@ -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)));
}

View File

@ -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;