폴더 선택 = 자기 자신과 안의 것 전체 · rsync 앱에 내장 (Windows · Android)

- 선택 모드: 폴더를 누르면 자기 자신과 안의 파일 · 폴더 모두 선택 (펼쳐서 ✔), 다시 누르면 모두 취소.
  고른 폴더 안의 항목 하나를 누르면 그것만 빠지고 나머지는 고른 채로
- rsync 내장: Windows 는 실행 파일 옆 rsync\ (tool/fetch_rsync.ps1, MSYS2 패키지 SHA256 고정),
  Android 는 NDK 로 rsync 3.4.1 소스를 빌드한 librsync.so (tool/build_rsync_android.sh, jniLibs)
- rsync 가져오기 기본 = 앱에 들어 있는 것 (Windows 는 없으면 내려받기), 폴더 복사 기본 = rsync (예전 기본값도 rsync 로)
- Android rsync 는 -8 로 한글 이름을 그대로 출력, lsync (실시간 동기화) 도 rsync 로 동작

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-05 13:57:59 +09:00
parent 967b729e50
commit c5a44f2f3f
18 changed files with 227 additions and 37 deletions

View File

@ -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 모델]

View File

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

View File

@ -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<String>("url") ?: ""))
"installApk" -> try {

View File

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

View File

@ -657,6 +657,7 @@
"앱 안 브라우저 (권장)": "アプリ内ブラウザー(推奨)",
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "アプリ内ブラウザーのログインを使う(ブラウザー画面で YouTube にログインしてください)",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)",
"앱에 들어 있는 것 (기본)": "アプリに内蔵 (既定)",
"앱을 다시 켤 때 동기화": "アプリ再起動時の同期",
"앱이 켜져 있는 동안 늘 동기화합니다.": "アプリの起動中は常に同期します。",
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "どこからでも押すと JJ_MKVMaker ウィンドウが表示されます",
@ -758,6 +759,8 @@
"이 버전에는 Windows 용 zip 이 없습니다.": "このバージョンには Windows 用 zip がありません。",
"이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "このコピーを次に行うときこのオプションを使います。今、新しいオプションで実行しますか?",
"이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "このブラウザーのブックマークファイルが見つかりませんでした。",
"이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "このインストールには rsync が含まれていません (rsync でコピーするとき · 右の [ダウンロード] で取得します)",
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "このインストールには rsync が含まれていません。直接指定してください。",
"이 언어 지우기": "この言語を削除",
"이 이동 버튼 지우기": "この移動ボタンを削除",
"이 파일에 맞는 자막 우선 (영상 해시)": "このファイルに合う字幕を優先(動画ハッシュ)",

View File

@ -657,6 +657,7 @@
"앱 안 브라우저 (권장)": "应用内浏览器(推荐)",
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "使用应用内浏览器的登录(请在浏览器界面登录 YouTube)",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)",
"앱에 들어 있는 것 (기본)": "应用内置 (默认)",
"앱을 다시 켤 때 동기화": "应用重新启动时同步",
"앱이 켜져 있는 동안 늘 동기화합니다.": "应用运行期间始终同步。",
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "在任何地方按下即可显示 JJ_MKVMaker 窗口",
@ -758,6 +759,8 @@
"이 버전에는 Windows 용 zip 이 없습니다.": "此版本没有 Windows 版 zip。",
"이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次进行此复制时将使用这些选项。现在用新选项运行吗?",
"이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "未找到此浏览器的书签文件。",
"이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "此安装中没有 rsync (使用 rsync 复制时 · 或用右侧 [下载] 获取)",
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "此安装中没有 rsync。请自行指定。",
"이 언어 지우기": "删除此语言",
"이 이동 버튼 지우기": "删除此移动按钮",
"이 파일에 맞는 자막 우선 (영상 해시)": "优先匹配此文件的字幕(视频哈希)",

View File

@ -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<void> 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<String>('nativeLibDir');
if (dir != null) path = p.join(dir, 'librsync.so');
} catch (_) {}
}
bundledRsync = path != null && File(path).existsSync() ? path : null;
}
/// 지금 설정으로 쓸 rsync 실행 파일 (없으면 null).
/// - 직접 지정: 그 파일
/// - 처음 쓸 때 내려받기 (Windows): 내려받아 둔 것
/// - 기본: 앱에 들어 있는 것, 없으면 (Windows) 내려받아 둔 것
Future<String?> 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<String?> 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)}/',

View File

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

View File

@ -72,6 +72,8 @@ List<String> 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 ? '/' : ''}',

View File

@ -93,6 +93,7 @@ Future<void> main(List<String> 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<void> 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);

View File

@ -147,10 +147,13 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
: _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<CopySyncSettings> {
DropdownButton<String>(
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 {

View File

@ -343,20 +343,54 @@ class _ExplorerPageState extends State<ExplorerPage> {
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<void> _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 = <String>[];
var dir = owner;
while (!samePath(dir, e.path)) {
await _load(pane, dir);
String? next;
for (final x in pane.cache[dir] ?? const <FileEntry>[]) {
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 <FileEntry>[]) x.path]);
pane.marked.remove(owner);
pane.marked.addAll(keep);
});
}
@ -467,7 +501,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
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<ExplorerPage> {
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<ExplorerPage> {
onPressed: () => setState(() {
final dir = listDir ?? pane.current;
pane.marked.addAll([for (final x in pane.cache[dir] ?? const <FileEntry>[]) x.path]);
_normalizeMarks(pane);
}),
child: Text(tr('모두 선택')),
),
@ -1390,7 +1425,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
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

View File

@ -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<IconButton>(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<IconButton>(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));

View File

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

View File

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

View File

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

33
tool/fetch_rsync.ps1 Normal file
View File

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

View File

@ -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()