diff --git a/THIRD_PARTY_NOTICES.txt b/THIRD_PARTY_NOTICES.txt index 4a6025c..dd22c11 100644 --- a/THIRD_PARTY_NOTICES.txt +++ b/THIRD_PARTY_NOTICES.txt @@ -48,6 +48,10 @@ JJ_MKVMaker - 포함된 오픈 소스 구성 요소 및 라이선스 고지 원문: tools\YT-DLP_LICENSE.txt - Deno (tools\deno.exe) MIT https://github.com/denoland/deno 원문: tools\DENO_LICENSE.txt +- rsync (rsync sync.exe) GPL-3.0-or-later https://rsync.samba.org + MSYS2 공식 패키지 rsync 3.5.1 과 실행에 필요한 DLL (msys2-runtime: GPL-3.0 + 예외, OpenSSL: Apache-2.0, + popt · lz4 · xxhash · zstd: BSD/MIT 계열, libiconv · libintl · libidn2 · libunistring: LGPL) + 소스: https://packages.msys2.org/base/rsync (각 패키지의 소스 패키지) 위 GPL 프로그램은 수정하지 않은 공식 배포 파일이며, 해당 소스는 위 주소에서 받을 수 있습니다. @@ -65,6 +69,9 @@ JJ_MKVMaker - 포함된 오픈 소스 구성 요소 및 라이선스 고지 · aria2 GPL-2.0-or-later https://github.com/aria2/aria2 수정하지 않은 공식 배포본 (Maven Central io.github.junkfood02.youtubedl-android 0.18.1) 이며, 소스는 위 주소에서 받을 수 있습니다. +- rsync (librsync.so 라는 이름의 실행 파일) GPL-3.0-or-later https://rsync.samba.org + rsync 3.4.1 공식 소스 (https://download.samba.org/pub/rsync/src/rsync-3.4.1.tar.gz) 를 고치지 않고 + Android NDK 로 빌드 (들어 있는 zlib · popt 사용, 선택 기능 끔). 빌드 방법: app ooluild_rsync_android.sh - libmpv (media_kit_libs_android_video) LGPL-2.1+ https://github.com/media-kit/libmpv-android-video-build [처음 사용할 때 내려받는 AI 모델] diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 05f2284..1ec3c6b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -58,6 +58,10 @@ android { } } + // rsync (librsync.so 라는 이름의 실행 파일): tool/build_rsync_android.sh 로 third_party/rsync/android 에 준비. + // 네이티브 라이브러리 폴더에 풀려 실행할 수 있다 (앱 데이터 폴더의 파일은 Android 10+ 에서 실행 불가) + sourceSets["main"].jniLibs.srcDir("../../third_party/rsync/android") + // ffmpeg · whisper 등 네이티브 라이브러리를 압축하지 않고 그대로 (설치 후 바로 로드) packaging { jniLibs { diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt index 11c40b9..f6f7867 100644 --- a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt @@ -110,6 +110,8 @@ class MainActivity : FlutterActivity() { result.success(null) } "storageRoot" -> result.success(Environment.getExternalStorageDirectory().path) + // 앱에 들어 있는 rsync (librsync.so) 가 풀리는 곳 + "nativeLibDir" -> result.success(applicationInfo.nativeLibraryDir) "storageVolumes" -> result.success(storageVolumes()) "openUrl" -> result.success(openUrl(call.argument("url") ?: "")) "installApk" -> try { diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 11376e5..5e3828c 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -657,6 +657,7 @@ "앱 안 브라우저 (권장)": "In-app browser (recommended)", "앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "Use the in-app browser's sign-in (sign in to YouTube on the browser screen)", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "In-app web browser: presses [Skip] for you, and mutes and fast-forwards ads that can't be skipped (\"Skipping ad…\" shows at the top while skipping)", + "앱에 들어 있는 것 (기본)": "Built into the app (default)", "앱을 다시 켤 때 동기화": "Sync when the app restarts", "앱이 켜져 있는 동안 늘 동기화합니다.": "Always syncs while the app is running.", "어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "Press anywhere to bring up the JJ_MKVMaker window", @@ -758,6 +759,8 @@ "이 버전에는 Windows 용 zip 이 없습니다.": "This version has no zip for Windows.", "이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "These options are used the next time you do this copy. Run now with the new options?", "이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "Could not find this browser's bookmarks file.", + "이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "This install has no rsync (downloaded when copying with rsync · or use [Download] on the right)", + "이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "This install has no rsync. Choose one yourself.", "이 언어 지우기": "Remove this language", "이 이동 버튼 지우기": "Delete this move button", "이 파일에 맞는 자막 우선 (영상 해시)": "Prefer subtitles that match this file (video hash)", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 66ea652..2d055a0 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -657,6 +657,7 @@ "앱 안 브라우저 (권장)": "アプリ内ブラウザー(推奨)", "앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "アプリ内ブラウザーのログインを使う(ブラウザー画面で YouTube にログインしてください)", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)", + "앱에 들어 있는 것 (기본)": "アプリに内蔵 (既定)", "앱을 다시 켤 때 동기화": "アプリ再起動時の同期", "앱이 켜져 있는 동안 늘 동기화합니다.": "アプリの起動中は常に同期します。", "어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "どこからでも押すと JJ_MKVMaker ウィンドウが表示されます", @@ -758,6 +759,8 @@ "이 버전에는 Windows 용 zip 이 없습니다.": "このバージョンには Windows 用 zip がありません。", "이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "このコピーを次に行うときこのオプションを使います。今、新しいオプションで実行しますか?", "이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "このブラウザーのブックマークファイルが見つかりませんでした。", + "이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "このインストールには rsync が含まれていません (rsync でコピーするとき · 右の [ダウンロード] で取得します)", + "이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "このインストールには rsync が含まれていません。直接指定してください。", "이 언어 지우기": "この言語を削除", "이 이동 버튼 지우기": "この移動ボタンを削除", "이 파일에 맞는 자막 우선 (영상 해시)": "このファイルに合う字幕を優先(動画ハッシュ)", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 158254a..36dbd94 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -657,6 +657,7 @@ "앱 안 브라우저 (권장)": "应用内浏览器(推荐)", "앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "使用应用内浏览器的登录(请在浏览器界面登录 YouTube)", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)", + "앱에 들어 있는 것 (기본)": "应用内置 (默认)", "앱을 다시 켤 때 동기화": "应用重新启动时同步", "앱이 켜져 있는 동안 늘 동기화합니다.": "应用运行期间始终同步。", "어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "在任何地方按下即可显示 JJ_MKVMaker 窗口", @@ -758,6 +759,8 @@ "이 버전에는 Windows 용 zip 이 없습니다.": "此版本没有 Windows 版 zip。", "이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次进行此复制时将使用这些选项。现在用新选项运行吗?", "이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "未找到此浏览器的书签文件。", + "이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "此安装中没有 rsync (使用 rsync 复制时 · 或用右侧 [下载] 获取)", + "이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "此安装中没有 rsync。请自行指定。", "이 언어 지우기": "删除此语言", "이 이동 버튼 지우기": "删除此移动按钮", "이 파일에 맞는 자막 우선 (영상 해시)": "优先匹配此文件的字幕(视频哈希)", diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart index d1fab9b..e924212 100644 --- a/lib/app/live_sync.dart +++ b/lib/app/live_sync.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; @@ -14,13 +15,33 @@ import '../platform/windows/rsync_installer.dart'; import 'app_controller.dart'; import 'settings.dart'; +/// 앱에 들어 있는 rsync (없으면 null). 시작할 때 [findBundledRsync] 로 정한다. +/// - Windows: 실행 파일 옆 rsync/rsync.exe (tool/fetch_rsync.ps1 로 준비해 빌드에 넣음) +/// - Android: 네이티브 라이브러리 폴더의 librsync.so (tool/build_rsync_android.sh, APK 의 jniLibs) +String? bundledRsync; + +Future findBundledRsync() async { + String? path; + if (Platform.isWindows) { + path = p.join(p.dirname(Platform.resolvedExecutable), 'rsync', 'rsync.exe'); + } else if (Platform.isAndroid) { + try { + final dir = await const MethodChannel('jj_mkvmaker/android').invokeMethod('nativeLibDir'); + if (dir != null) path = p.join(dir, 'librsync.so'); + } catch (_) {} + } + bundledRsync = path != null && File(path).existsSync() ? path : null; +} + /// 지금 설정으로 쓸 rsync 실행 파일 (없으면 null). /// - 직접 지정: 그 파일 -/// - 처음 쓸 때 내려받기 (Windows): 내려받아 둔 것 +/// - 기본: 앱에 들어 있는 것, 없으면 (Windows) 내려받아 둔 것 Future rsyncExecutable(AppSettings s) async { if (s.rsyncSource == 'custom') { return s.rsyncPath.isNotEmpty && File(s.rsyncPath).existsSync() ? s.rsyncPath : null; } + final b = bundledRsync; + if (b != null && File(b).existsSync()) return b; if (!Platform.isWindows) return null; return RsyncInstaller.installedPath(); } @@ -29,7 +50,7 @@ Future rsyncExecutable(AppSettings s) async { bool copyMethodAvailable(CopyMethod m, AppSettings s) => switch (m) { CopyMethod.builtin => true, CopyMethod.robocopy => Platform.isWindows, - CopyMethod.rsync => Platform.isWindows || s.rsyncSource == 'custom', + CopyMethod.rsync => Platform.isWindows || s.rsyncSource == 'custom' || bundledRsync != null, }; /// 실시간 동기화 (lsyncd 처럼): 설정의 폴더 쌍마다 원본을 지켜보다가 바뀌면 대상에 맞춘다. @@ -258,6 +279,7 @@ class LiveSync extends ChangeNotifier { final win = Platform.isWindows; final args = [ ...splitOptions(s.rsyncOptions).where((o) => o != '-P' && o != '--progress'), + if (!win) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음) if (s.copyBandwidthKBps > 0) '--bwlimit=${s.copyBandwidthKBps}', if (x.delete) '--delete', '${toCygwinPath(x.source, windows: win)}/', diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 25dca31..328c5cf 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -298,7 +298,8 @@ class AppSettings { // ── 복사 · 이동 · 동기화 (파일 탐색기) ── /// 방법: 'builtin' 현재 방식 (기본) · 'rsync' · 'robocopy' (Windows). 파일만 고를 때 / 폴더가 들어 있을 때 String copyMethodFile = 'builtin'; - String copyMethodFolder = 'builtin'; + /// 폴더 복사 · 이동 기본: rsync (앱에 들어 있음. 이 기기에서 못 쓰면 현재 방식으로) + String copyMethodFolder = 'rsync'; String rsyncOptions = '-avPog'; String robocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2'; @@ -308,7 +309,7 @@ class AppSettings { /// 속도 제한 KB/s (0 = 제한 없음). 모든 방법에 (rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게) int copyBandwidthKBps = 0; - /// rsync 가져오기: 'download' 처음 쓸 때 내려받기 (Windows 기본) · 'custom' 직접 지정한 실행 파일 ([rsyncPath]) + /// rsync 가져오기: 'download' 앱에 들어 있는 것 (기본, Windows 는 없으면 내려받기) · 'custom' 직접 지정한 실행 파일 ([rsyncPath]) String rsyncSource = 'download'; String rsyncPath = ''; @@ -423,6 +424,7 @@ class AppSettings { 'explorerPaths': explorerPaths, 'copyMethodFile': copyMethodFile, 'copyMethodFolder': copyMethodFolder, + 'copyDefaults': 2, 'rsyncOptions': rsyncOptions, 'robocopyOptions': robocopyOptions, 'copyRunMode': copyRunMode, @@ -525,7 +527,10 @@ class AppSettings { ..explorerShowHidden = j['explorerShowHidden'] as bool? ?? false ..explorerPaths = [for (final x in (j['explorerPaths'] as List?) ?? const []) '$x'] ..copyMethodFile = _method(j['copyMethodFile']) - ..copyMethodFolder = _method(j['copyMethodFolder']) + // rsync 를 앱에 넣기 전 (copyDefaults 없음) 의 기본 '현재 방식' 은 새 기본 rsync 로 + ..copyMethodFolder = j['copyDefaults'] == null && (j['copyMethodFolder'] ?? 'builtin') == 'builtin' + ? 'rsync' + : _method(j['copyMethodFolder']) ..rsyncOptions = j['rsyncOptions'] as String? ?? '-avPog' ..robocopyOptions = j['robocopyOptions'] as String? ?? '/E /COPY:DAT /DCOPY:T /R:2 /W:2' ..copyRunMode = j['copyRunMode'] == 'once' ? 'once' : 'each' diff --git a/lib/core/sync_tools.dart b/lib/core/sync_tools.dart index c07d61a..355027c 100644 --- a/lib/core/sync_tools.dart +++ b/lib/core/sync_tools.dart @@ -72,6 +72,8 @@ List rsyncArgs({ return [ ...opts, if (bandwidthKBps > 0 && !opts.any((o) => o.startsWith('--bwlimit'))) '--bwlimit=$bandwidthKBps', + // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 출력 (Android 빌드는 iconv 없음 · 진행 표시에 쓰는 이름) + if (!windows && !opts.contains('-8') && !opts.contains('--8-bit-output')) '-8', if (move && !opts.contains('--remove-source-files')) '--remove-source-files', // [contents]: 폴더 "안의 것" 을 대상에 (rsync 의 끝 / - 동기화 · lsync 와 같은 모양) for (final s in sources) '${toCygwinPath(_noTrailingSlash(s), windows: windows)}${contents ? '/' : ''}', diff --git a/lib/main.dart b/lib/main.dart index 3f995dd..d318dc5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -93,6 +93,7 @@ Future main(List args) async { } await controller.init(); // 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함 + await findBundledRsync(); // 앱에 들어 있는 rsync final live = LiveSync(controller)..start(hold: controller.settings.liveSyncOnStart != 'auto'); // 화면 언어 (환경 설정 > 화면 언어) i18n.init(controller, dataDir); @@ -378,6 +379,7 @@ Future runAndroid(String dataDir) async { controller.note(trf('── 시작 {0} (Android) ──', [appTitle])); await controller.init(); // 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함 + await findBundledRsync(); // 앱에 들어 있는 rsync final live = LiveSync(controller)..start(hold: controller.settings.liveSyncOnStart != 'auto'); // 화면 언어 (환경 설정 > 화면 언어) i18n.init(controller, dataDir); diff --git a/lib/ui/copy_sync_settings.dart b/lib/ui/copy_sync_settings.dart index 11cfcfd..0ed995e 100644 --- a/lib/ui/copy_sync_settings.dart +++ b/lib/ui/copy_sync_settings.dart @@ -147,10 +147,13 @@ class _CopySyncSettingsState extends State { : _rsync != null ? trf('사용: {0}', [_rsync!]) : s.rsyncSource == 'download' - ? (desk ? tr('아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)') : tr('Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.')) + ? (desk + ? tr('이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)') + : tr('이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.')) : tr('지정한 파일이 없습니다')), trailing: Row(mainAxisSize: MainAxisSize.min, children: [ - if (desk && s.rsyncSource == 'download' && !_checking) + // 내려받기 · 내려받은 것 지우기 (앱에 들어 있는 것을 쓰면 보이지 않음) + if (desk && s.rsyncSource == 'download' && !_checking && (_rsync == null || _rsync != bundledRsync)) _rsync == null ? TextButton( onPressed: () async { @@ -171,7 +174,7 @@ class _CopySyncSettingsState extends State { DropdownButton( value: s.rsyncSource, items: [ - DropdownMenuItem(value: 'download', child: Text(desk ? tr('처음 쓸 때 내려받기 (기본)') : tr('내려받기 (Windows 만)'))), + DropdownMenuItem(value: 'download', child: Text(tr('앱에 들어 있는 것 (기본)'))), DropdownMenuItem(value: 'custom', child: Text(tr('직접 지정'))), ], onChanged: (v) async { diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 429527a..db4d976 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -343,20 +343,54 @@ class _ExplorerPageState extends State { pane.changed(); } - /// 선택 모드에서 누르기: 파일은 고르기 · 풀기. - /// 폴더는 처음엔 고르기, 고른 폴더를 다시 누르면 폴더는 풀고 펼쳐서 그 안의 파일 · 폴더를 모두 고른다. + /// 표시되었는지: 자기 자신, 또는 위 폴더가 표시되었으면 (폴더를 고르면 안의 것도 모두 고른 것) + bool _isMarked(_Pane pane, String path) => pane.marked.any((m) => isSameOrInside(path, m)); + + /// 다른 표시 안에 든 표시는 뺀다 (폴더 하나로 안의 것까지 고른 것이므로) + void _normalizeMarks(_Pane pane) { + final all = pane.marked.toList(); + pane.marked.removeWhere((m) => all.any((o) => !identical(o, m) && !samePath(o, m) && isSameOrInside(m, o))); + } + + /// 선택 모드에서 누르기. 폴더는 자기 자신과 안의 파일 · 폴더 전체가 한 묶음이다. + /// - 고르지 않은 것을 누르면 고른다 (폴더는 펼쳐서 안의 것도 모두 고른 모양으로 보여 줌) + /// - 고른 것을 다시 누르면 자기 자신과 안의 것을 모두 푼다. + /// 위 폴더를 골라서 함께 골라진 것이면 그것만 빼고, 같은 폴더의 나머지는 고른 채로 둔다. Future _toggleMark(_Pane pane, FileEntry e) async { pane.focused = e.path; - if (!pane.marked.contains(e.path)) { - setState(() => pane.marked.add(e.path)); + if (!_isMarked(pane, e.path)) { + setState(() { + pane.marked.removeWhere((m) => isSameOrInside(m, e.path)); // 안에 따로 고른 것은 이 폴더 하나로 + pane.marked.add(e.path); + }); + if (e.isDir) { + await _load(pane, e.path); + setState(() => pane.expanded.add(e.path)); + } return; } - setState(() => pane.marked.remove(e.path)); - if (!e.isDir) return; - await _load(pane, e.path); + final owner = pane.marked.firstWhere((m) => isSameOrInside(e.path, m)); + setState(() => pane.marked.removeWhere((m) => isSameOrInside(m, e.path))); + if (samePath(owner, e.path)) return; + // 위 폴더 [owner] 를 골라 둔 상태: owner 표시를 풀고, e 까지 내려가는 길의 다른 항목은 고른 채로 + final keep = []; + var dir = owner; + while (!samePath(dir, e.path)) { + await _load(pane, dir); + String? next; + for (final x in pane.cache[dir] ?? const []) { + if (isSameOrInside(e.path, x.path)) { + next = x.path; + } else { + keep.add(x.path); + } + } + if (next == null) break; + dir = next; + } setState(() { - pane.expanded.add(e.path); - pane.marked.addAll([for (final x in pane.cache[e.path] ?? const []) x.path]); + pane.marked.remove(owner); + pane.marked.addAll(keep); }); } @@ -467,7 +501,7 @@ class _ExplorerPageState extends State { await _addMkv([e.path]); case 'select': setState(() => _selecting = true); - if (!pane.marked.contains(e.path)) await _toggleMark(pane, e); + if (!_isMarked(pane, e.path)) await _toggleMark(pane, e); case 'rename': await _rename(pane, e.path); case 'copy': @@ -475,9 +509,9 @@ class _ExplorerPageState extends State { case 'move': await _transfer(pane, [e.path], move: true); case 'clipCopy': - _toClipboard(pane.marked.contains(e.path) ? pane.marked.toList() : [e.path], move: false); + _toClipboard(_isMarked(pane, e.path) ? pane.marked.toList() : [e.path], move: false); case 'clipMove': - _toClipboard(pane.marked.contains(e.path) ? pane.marked.toList() : [e.path], move: true); + _toClipboard(_isMarked(pane, e.path) ? pane.marked.toList() : [e.path], move: true); case 'paste': await _paste(pane, e.isDir ? e.path : p.dirname(e.path)); case 'delete': @@ -1291,6 +1325,7 @@ class _ExplorerPageState extends State { onPressed: () => setState(() { final dir = listDir ?? pane.current; pane.marked.addAll([for (final x in pane.cache[dir] ?? const []) x.path]); + _normalizeMarks(pane); }), child: Text(tr('모두 선택')), ), @@ -1390,7 +1425,7 @@ class _ExplorerPageState extends State { final look = _look; final isCurrent = e.isDir && !list && !row.isUp && samePath(pane.current, e.path); final isFocused = !row.isUp && pane.focused != null && samePath(pane.focused!, e.path); - final marked = pane.marked.contains(e.path); + final marked = _isMarked(pane, e.path); final open = e.isDir && !list && pane.expanded.contains(e.path); final loading = pane.loading.contains(e.path); final name = row.isUp diff --git a/test/explorer_test.dart b/test/explorer_test.dart index a845b0c..ac567d6 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -165,7 +165,8 @@ void main() { expect(find.text('확장자'), findsOneWidget); }); - testWidgets('선택 모드: 파일 누르기 = 선택 · 취소, 고른 폴더 다시 누르기 = 펼쳐서 안의 것 선택', (tester) async { + testWidgets('선택 모드: 파일 누르기 = 선택 · 취소, 폴더 누르기 = 자기 자신과 안의 것 모두 선택 · 다시 누르면 모두 취소', (tester) async { + File(p.join(left, 'sub', 'second.txt')).writeAsStringSync('2'); await open(tester); await act(tester, () => tester.tap(find.text('선택').first)); // 가운데 [선택] 버튼 expect(find.byTooltip('표시 (여러 개 고르기)'), findsWidgets); // 동그라미가 보인다 @@ -175,24 +176,37 @@ void main() { await act(tester, () => tester.tap(find.text('doc.txt'))); expect(find.text('0개 표시함'), findsNWidgets(2)); // 두 창 모두 (각 창의 선택 수) expect(shell.opened, isEmpty); - // 폴더: 처음엔 선택 (펼치지 않음) - await act(tester, () => tester.tap(find.text('sub'))); - await settle(tester, () => false, rounds: 10); - expect(find.text('1개 표시함'), findsOneWidget); - expect(find.text('inner.txt'), findsNothing); - // 고른 폴더를 다시 누르면: 폴더는 풀고, 펼쳐서 안의 파일을 선택 + + IconData markIcon(String name) => (tester + .widget(find.descendant( + of: find.ancestor(of: find.text(name), matching: find.byType(Material)).first, + matching: find.byType(IconButton))) + .icon as Icon) + .icon!; + + // 폴더: 누르면 자기 자신과 안의 것 모두 선택 (펼쳐서 ✔ 로 보여 줌) await act(tester, () => tester.tap(find.text('sub'))); await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); - expect(find.text('inner.txt'), findsOneWidget); + expect(find.text('1개 표시함'), findsOneWidget); // 폴더 하나로 안의 것까지 + expect([markIcon('sub'), markIcon('inner.txt'), markIcon('second.txt')], everyElement(Icons.check_circle)); + // 안의 것 하나를 누르면 그것만 빠지고 나머지는 고른 채로 + await act(tester, () => tester.tap(find.text('inner.txt'))); + await settle(tester, () => false, rounds: 5); + expect(markIcon('inner.txt'), Icons.check_circle_outline); + expect(markIcon('second.txt'), Icons.check_circle); + expect(markIcon('sub'), Icons.check_circle_outline); + // 폴더를 다시 누르면 다시 전체 선택, 한 번 더 누르면 자기 자신과 안의 것 모두 취소 + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => false, rounds: 5); expect(find.text('1개 표시함'), findsOneWidget); - final inner = tester.widget(find.descendant( - of: find.ancestor(of: find.text('inner.txt'), matching: find.byType(Material)).first, - matching: find.byType(IconButton))); - expect((inner.icon as Icon).icon, Icons.check_circle); // ✔ - // [선택 끝] → 동그라미가 사라지고 표시도 지운다 + expect([markIcon('sub'), markIcon('inner.txt'), markIcon('second.txt')], everyElement(Icons.check_circle)); + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => false, rounds: 5); + expect(find.text('0개 표시함'), findsNWidgets(2)); + expect([markIcon('sub'), markIcon('inner.txt'), markIcon('second.txt')], everyElement(Icons.check_circle_outline)); + // [선택 끝] → 동그라미가 사라진다 await act(tester, () => tester.tap(find.text('선택 끝').first)); // 두 창 모두에 있음 expect(find.byTooltip('표시 (여러 개 고르기)'), findsNothing); - expect(find.text('1개 표시함'), findsNothing); }); testWidgets('여러 개 표시 → 삭제 · 새 폴더 · 이름 변경', (tester) async { @@ -200,6 +214,7 @@ void main() { await act(tester, () => tester.tap(find.text('선택').first)); // 동그라미로 doc.txt · sub 표시 (반드시 이름으로 찾은 줄의 버튼만: 목록에는 시험 폴더 위쪽의 실제 폴더도 보인다) await act(tester, () => tester.tap(markOf('sub'))); + await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); // 폴더를 고르면 펼쳐서 안의 것도 ✔ await act(tester, () => tester.tap(markOf('doc.txt'))); expect(find.text('2개 표시함'), findsOneWidget); await act(tester, () => tester.tap(find.text('삭제').first)); diff --git a/test/monitor_test.dart b/test/monitor_test.dart index 4231cde..4ca8332 100644 --- a/test/monitor_test.dart +++ b/test/monitor_test.dart @@ -68,6 +68,7 @@ void main() { tearDown(() => tmp.deleteSync(recursive: true)); test('같은 복사는 기억한 옵션 · 실행 결과 기록 · lsync 로 이동 · 다시 복사 목록으로', () async { + c.settings.copyMethodFolder = 'builtin'; // rsync 실행은 sync_tools_test (시험 환경에서는 path_provider 없음) final center = CopyCenter(c); final t = await center.remember([src], dst); await center.update(t.copyWith(options: '-a --custom', bandwidthKBps: 77)); diff --git a/test/sync_tools_test.dart b/test/sync_tools_test.dart index 0f59e1a..7c85d01 100644 --- a/test/sync_tools_test.dart +++ b/test/sync_tools_test.dart @@ -33,7 +33,18 @@ void main() { ['-avPog', '--bwlimit=500', '--remove-source-files', '/cygdrive/c/a/src', '/cygdrive/c/a/f.mkv', '/cygdrive/e/dst/']); // 옵션 칸에 직접 넣은 속도 제한이 우선 expect(rsyncArgs(options: '-a --bwlimit=10', sources: ['/x'], dest: '/y', bandwidthKBps: 500, windows: false), - ['-a', '--bwlimit=10', '/x', '/y/']); + ['-a', '--bwlimit=10', '-8', '/x', '/y/']); // Android: 한글 이름을 그대로 출력 (-8) + }); + + test('기본값: 폴더 복사는 rsync (앱에 들어 있음), 예전 설정의 기본값 현재 방식도 rsync 로', () { + expect(AppSettings().copyMethodFolder, 'rsync'); + expect(AppSettings().copyMethodFile, 'builtin'); + expect(AppSettings.fromJson({}).copyMethodFolder, 'rsync'); + expect(AppSettings.fromJson({'copyMethodFolder': 'builtin'}).copyMethodFolder, 'rsync'); // 예전 저장값 + // 새 버전에서 직접 고른 현재 방식은 그대로 + final s = AppSettings()..copyMethodFolder = 'builtin'; + expect(AppSettings.fromJson(s.toJson()).copyMethodFolder, 'builtin'); + expect(AppSettings.fromJson({'copyMethodFolder': 'robocopy'}).copyMethodFolder, 'robocopy'); }); test('robocopy: 폴더마다 · 같은 폴더 파일은 한 번에 · /IPG · 이동', () { @@ -196,7 +207,7 @@ void main() { final back = AppSettings.fromJson(c.settings.toJson()); expect(back.liveSyncPairs.single.target, p.join(src, 'in')); expect([back.copyMethodFolder, back.rsyncOptions, back.copyRunMode, back.rsyncSource], - ['builtin', '-avPog', 'each', 'download']); + ['rsync', '-avPog', 'each', 'download']); }); }); } diff --git a/tool/build_rsync_android.sh b/tool/build_rsync_android.sh new file mode 100644 index 0000000..ba70a73 --- /dev/null +++ b/tool/build_rsync_android.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# Android (arm64) 용 rsync 를 소스에서 빌드해 third_party/rsync/android/arm64-v8a/librsync.so 로 둔다 +# (APK 의 jniLibs 로 들어가 앱의 네이티브 라이브러리 폴더에서 실행된다. Android 의 libc 만 쓴다). +# 소스: https://download.samba.org/pub/rsync/src/rsync-3.4.1.tar.gz (SHA256 고정, 고치지 않음) +# 선택 기능 (openssl · xxhash · zstd · lz4 · iconv · ACL · xattr) 은 끄고, 들어 있는 zlib · popt 를 쓴다. +# 사용법 (Git Bash): sh tool/build_rsync_android.sh - Android NDK 가 필요 (ANDROID_NDK 또는 tools/android-sdk/ndk/*) +set -e +APP=$(cd "$(dirname "$0")/.." && pwd) +VER=3.4.1 +SHA=2924bcb3a1ed8b551fc101f740b9f0fe0a202b115027647cf69850d65fd88c52 +NDK_ROOT=${ANDROID_NDK:-$(ls -d "$APP"/../tools/android-sdk/ndk/* | sort | tail -1)} +case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) HOST=windows-x86_64; EXE=.exe ;; Darwin) HOST=darwin-x86_64; EXE= ;; *) HOST=linux-x86_64; EXE= ;; esac +TC=$NDK_ROOT/toolchains/llvm/prebuilt/$HOST/bin +MAKE=$NDK_ROOT/prebuilt/$HOST/bin/make$EXE +[ -x "$MAKE" ] || MAKE=make +WORK=${TMPDIR:-/tmp}/jj_rsync_android +rm -rf "$WORK" && mkdir -p "$WORK" && cd "$WORK" +curl -sSLf -o rsync.tar.gz "https://download.samba.org/pub/rsync/src/rsync-$VER.tar.gz" +echo "$SHA rsync.tar.gz" | sha256sum -c - +tar xzf rsync.tar.gz 2>/dev/null || true # md2man 심볼릭 링크 오류는 무시 (문서용) +cd "rsync-$VER" +export CC="$TC/clang$EXE --target=aarch64-linux-android24" AR="$TC/llvm-ar$EXE" RANLIB="$TC/llvm-ranlib$EXE" +./configure --host=aarch64-linux-android --disable-openssl --disable-xxhash --disable-zstd --disable-lz4 \ + --disable-md2man --disable-iconv --disable-acl-support --disable-xattr-support \ + --with-included-zlib --with-included-popt --disable-ipv6 > configure.log +"$MAKE" SHELL=/bin/sh rsync > make.log 2>&1 +OUT="$APP/third_party/rsync/android/arm64-v8a" +mkdir -p "$OUT" +"$TC/llvm-strip$EXE" -o "$OUT/librsync.so" rsync +ls -l "$OUT/librsync.so" diff --git a/tool/fetch_rsync.ps1 b/tool/fetch_rsync.ps1 new file mode 100644 index 0000000..561e768 --- /dev/null +++ b/tool/fetch_rsync.ps1 @@ -0,0 +1,33 @@ +# rsync 를 third_party\rsync\windows 로 준비한다 (빌드 시 앱의 rsync\ 에 동봉됨, 내려받기 없이 바로 사용). +# MSYS2 공식 저장소의 rsync 와 필요한 DLL. 패키지 목록 · SHA256 은 lib\core\sync_tools.dart 의 것을 그대로 쓴다. +# Android 용은 tool\build_rsync_android.sh (NDK 로 소스에서 빌드). +# 사용법: powershell -File tool\fetch_rsync.ps1 +$ErrorActionPreference = 'Stop' +$src = Get-Content (Join-Path $PSScriptRoot '..\lib\core\sync_tools.dart') -Raw -Encoding UTF8 +$repo = [regex]::Match($src, "const msys2Repo = '([^']+)'").Groups[1].Value +$block = [regex]::Match($src, 'msys2RsyncPackages = <\(String, String\)>\[(.*?)\];', 'Singleline').Groups[1].Value +$pkgs = [regex]::Matches($block, "\('([^']+)', '([0-9a-f]{64})'\)") +$filesBlock = [regex]::Match($src, 'msys2RsyncFiles = \{(.*?)\};', 'Singleline').Groups[1].Value +$files = [regex]::Matches($filesBlock, "'([^']+)'") | ForEach-Object { $_.Groups[1].Value } +if (-not $repo -or $pkgs.Count -eq 0 -or $files.Count -eq 0) { throw 'sync_tools.dart 에서 패키지 목록을 읽지 못했습니다' } + +$dest = Join-Path $PSScriptRoot '..\third_party\rsync\windows' +$tmp = Join-Path $env:TEMP 'jj_rsync_fetch' +Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force $tmp, $dest | Out-Null +foreach ($m in $pkgs) { + $name = $m.Groups[1].Value; $sha = $m.Groups[2].Value + $f = Join-Path $tmp $name + curl.exe -L -s -f -o $f "$repo$name" + if ($LASTEXITCODE -ne 0) { throw "$name 을(를) 받지 못했습니다" } + $got = (Get-FileHash $f -Algorithm SHA256).Hash.ToLower() + if ($got -ne $sha) { throw "$name 의 SHA256 이 다릅니다 ($got)" } + tar.exe -xf $f -C $tmp usr/bin + if ($LASTEXITCODE -ne 0) { throw "$name 을(를) 풀지 못했습니다" } + Write-Host "확인: $name" +} +Get-ChildItem $dest | Remove-Item -Force +foreach ($n in $files) { Copy-Item (Join-Path $tmp "usr\bin\$n") $dest -Force } +& (Join-Path $dest 'rsync.exe') --version | Select-Object -First 1 +Remove-Item $tmp -Recurse -Force +Write-Host "완료: $dest" diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 1da1539..e405552 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -136,3 +136,12 @@ if(JJ_TOOL_FILES) install(FILES ${JJ_TOOL_FILES} DESTINATION "${CMAKE_INSTALL_PREFIX}/tools" COMPONENT Runtime) endif() + +# JJ_MKVMaker: rsync 동봉 (내려받기 없이 바로). 실행 파일 옆 rsync\ 폴더로 복사. +# 파일은 tool\fetch_rsync.ps1 로 third_party\rsync\windows 에 준비한다. +set(JJ_RSYNC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../third_party/rsync/windows") +file(GLOB JJ_RSYNC_FILES "${JJ_RSYNC_DIR}/*.exe" "${JJ_RSYNC_DIR}/*.dll") +if(JJ_RSYNC_FILES) + install(FILES ${JJ_RSYNC_FILES} + DESTINATION "${CMAKE_INSTALL_PREFIX}/rsync" COMPONENT Runtime) +endif()