폴더 선택 = 자기 자신과 안의 것 전체 · 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
parent
967b729e50
commit
c5a44f2f3f
|
|
@ -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 모델]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -657,6 +657,7 @@
|
|||
"앱 안 브라우저 (권장)": "アプリ内ブラウザー(推奨)",
|
||||
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "アプリ内ブラウザーのログインを使う(ブラウザー画面で YouTube にログインしてください)",
|
||||
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)",
|
||||
"앱에 들어 있는 것 (기본)": "アプリに内蔵 (既定)",
|
||||
"앱을 다시 켤 때 동기화": "アプリ再起動時の同期",
|
||||
"앱이 켜져 있는 동안 늘 동기화합니다.": "アプリの起動中は常に同期します。",
|
||||
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "どこからでも押すと JJ_MKVMaker ウィンドウが表示されます",
|
||||
|
|
@ -758,6 +759,8 @@
|
|||
"이 버전에는 Windows 용 zip 이 없습니다.": "このバージョンには Windows 用 zip がありません。",
|
||||
"이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "このコピーを次に行うときこのオプションを使います。今、新しいオプションで実行しますか?",
|
||||
"이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "このブラウザーのブックマークファイルが見つかりませんでした。",
|
||||
"이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "このインストールには rsync が含まれていません (rsync でコピーするとき · 右の [ダウンロード] で取得します)",
|
||||
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "このインストールには rsync が含まれていません。直接指定してください。",
|
||||
"이 언어 지우기": "この言語を削除",
|
||||
"이 이동 버튼 지우기": "この移動ボタンを削除",
|
||||
"이 파일에 맞는 자막 우선 (영상 해시)": "このファイルに合う字幕を優先(動画ハッシュ)",
|
||||
|
|
|
|||
|
|
@ -657,6 +657,7 @@
|
|||
"앱 안 브라우저 (권장)": "应用内浏览器(推荐)",
|
||||
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "使用应用内浏览器的登录(请在浏览器界面登录 YouTube)",
|
||||
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)",
|
||||
"앱에 들어 있는 것 (기본)": "应用内置 (默认)",
|
||||
"앱을 다시 켤 때 동기화": "应用重新启动时同步",
|
||||
"앱이 켜져 있는 동안 늘 동기화합니다.": "应用运行期间始终同步。",
|
||||
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "在任何地方按下即可显示 JJ_MKVMaker 窗口",
|
||||
|
|
@ -758,6 +759,8 @@
|
|||
"이 버전에는 Windows 용 zip 이 없습니다.": "此版本没有 Windows 版 zip。",
|
||||
"이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次进行此复制时将使用这些选项。现在用新选项运行吗?",
|
||||
"이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "未找到此浏览器的书签文件。",
|
||||
"이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "此安装中没有 rsync (使用 rsync 复制时 · 或用右侧 [下载] 获取)",
|
||||
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "此安装中没有 rsync。请自行指定。",
|
||||
"이 언어 지우기": "删除此语言",
|
||||
"이 이동 버튼 지우기": "删除此移动按钮",
|
||||
"이 파일에 맞는 자막 우선 (영상 해시)": "优先匹配此文件的字幕(视频哈希)",
|
||||
|
|
|
|||
|
|
@ -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)}/',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 ? '/' : ''}',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue