Compare commits
No commits in common. "기능추가1" and "secret_test" have entirely different histories.
기능추가1
...
secret_tes
|
|
@ -1,7 +1,5 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- 28 APK 점검 · 관리자 판단 (10/19): Wi-Fi 공유 · SMB 찾기가 망 종류 (Wi-Fi · 유선 / 모바일 데이터 · VPN) 를 알기 위해. 일반 권한 (묻지 않음) -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<!-- 동영상 옆 jj_mkv 폴더에 MKV 를 만들기 위한 저장소 접근 -->
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
||||
|
|
@ -137,15 +135,13 @@
|
|||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/jj_file_paths"/>
|
||||
</provider>
|
||||
<!-- 작업 알림 서비스 (28 APK 점검 · 관리자 지시 10/19): specialUse 설명은 실제로 하는 일 모두를 적는다
|
||||
(실시간 동기화 · 동영상 변환 · AI 자막 · 다운로드 · 복사/이동 · 원격 서버 전송 · Wi-Fi 공유) -->
|
||||
<service
|
||||
android:name=".KeepAliveService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync|specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Keeps user-started work running while the app is in the background (user setting: run in background): real-time folder sync, video to MKV conversion and AI subtitles, downloads, file copy/move, transfers to the user's own SFTP/SMB/FTP/WebDAV servers, and the read-only Wi-Fi file sharing server. A notification shows what is running."/>
|
||||
android:value="Keeps folder live sync, video conversion and downloads running while the app is closed (user setting: run in background)"/>
|
||||
</service>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
|
|
|
|||
|
|
@ -170,8 +170,6 @@ class MainActivity : FlutterActivity() {
|
|||
startActivity(Intent(Intent.ACTION_DELETE, Uri.parse("package:$packageName")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
result.success(null)
|
||||
}
|
||||
// 28 APK 점검 · 관리자 판단 (10/19): 주소마다 망 종류 - Wi-Fi 공유 · SMB 찾기가 Wi-Fi · 유선만 쓰게 (모바일 데이터 · VPN 은 뺀다)
|
||||
"netKinds" -> result.success(netKinds())
|
||||
"diskSpace" -> result.success(
|
||||
try {
|
||||
val st = android.os.StatFs(call.argument<String>("path") ?: "")
|
||||
|
|
@ -621,29 +619,4 @@ class MainActivity : FlutterActivity() {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// IPv4 주소 → 망 종류 ("wifi" · "ethernet" · "cellular" · "vpn" · "other"). 읽지 못하면 null (Dart 가 이름 방식으로)
|
||||
@Suppress("DEPRECATION")
|
||||
private fun netKinds(): Map<String, String>? = try {
|
||||
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
|
||||
val out = HashMap<String, String>()
|
||||
for (n in cm.allNetworks) {
|
||||
val caps = cm.getNetworkCapabilities(n) ?: continue
|
||||
val lp = cm.getLinkProperties(n) ?: continue
|
||||
val kind = when {
|
||||
caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN) -> "vpn"
|
||||
caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
|
||||
caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
|
||||
caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
|
||||
else -> "other"
|
||||
}
|
||||
for (la in lp.linkAddresses) {
|
||||
val a = la.address
|
||||
if (a is java.net.Inet4Address) a.hostAddress?.let { out[it] = kind }
|
||||
}
|
||||
}
|
||||
out
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,10 +86,8 @@ object SmbBridge {
|
|||
val c = conn(call)
|
||||
val path = smbPath(call.argument<String>("path") ?: "")
|
||||
return when (call.method) {
|
||||
// 보안 검토 6: 이 연결 (세션) 이 실제로 암호화 · 서명되는지
|
||||
"info" -> mapOf(
|
||||
"encrypted" to runCatching { c.session.shouldEncryptData() }.getOrDefault(false),
|
||||
"signed" to c.session.isSigningRequired,
|
||||
"encrypted" to (c.connection.connectionContext.supportsEncryption()),
|
||||
"dialect" to c.connection.connectionContext.negotiatedProtocol.dialect.name,
|
||||
)
|
||||
"list" -> c.share.list(path).filter { it.fileName != "." && it.fileName != ".." }.map {
|
||||
|
|
@ -175,8 +173,7 @@ object SmbBridge {
|
|||
val user = call.argument<String>("user") ?: ""
|
||||
val domain = call.argument<String>("domain") ?: ""
|
||||
val password = call.argument<String>("password") ?: ""
|
||||
val requireSecure = call.argument<Boolean>("requireSecure") ?: false
|
||||
val key = listOf(host, port, shareName, user, domain, password.hashCode(), requireSecure).joinToString("|")
|
||||
val key = listOf(host, port, shareName, user, domain, password.hashCode()).joinToString("|")
|
||||
conns[id]?.let {
|
||||
if (it.key == key && it.connection.isConnected) return it
|
||||
conns.remove(id)
|
||||
|
|
@ -186,8 +183,6 @@ object SmbBridge {
|
|||
// Android 의 기본 보안 제공자에는 NTLM 에 필요한 MD4 가 없다 - Bouncy Castle 로
|
||||
.withSecurityProvider(BCSecurityProvider())
|
||||
.withEncryptData(true)
|
||||
// 보안 검토 6: 설정 "SMB 서명 · 암호화 필수" 면 서명을 요구한다 (서버가 서명을 켜 두었으면 서명)
|
||||
.withSigningRequired(requireSecure)
|
||||
.withTimeout(30, TimeUnit.SECONDS)
|
||||
.withSoTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
|
@ -197,10 +192,6 @@ object SmbBridge {
|
|||
val auth = if (user.isEmpty()) AuthenticationContext.guest()
|
||||
else AuthenticationContext(user, password.toCharArray(), domain.ifEmpty { null })
|
||||
val session = connection.authenticate(auth)
|
||||
if (requireSecure && !session.isSigningRequired && !runCatching { session.shouldEncryptData() }.getOrDefault(false)) {
|
||||
runCatching { session.close() }
|
||||
throw SmbBridgeException("insecure", "signing and encryption are off")
|
||||
}
|
||||
val share = session.connectShare(shareName) as? DiskShare
|
||||
?: throw SmbBridgeException("noShare", "공유 폴더가 아닙니다: $shareName")
|
||||
val c = Conn(key, client, connection, session, share)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -52,8 +52,6 @@ WHISPER_CPP_LICENSE.txt · ONNXRUNTIME_LICENSES.txt 는 실행 파일 옆에도
|
|||
Copyright (c) 2013-2021 Brendan Duncan.
|
||||
- dartssh2 4.1.0 (SFTP) MIT https://pub.dev/packages/dartssh2
|
||||
Copyright (c) 2022 xuty. 함께 쓰는 pointycastle (MIT) · pinenacl (MIT) · asn1lib (BSD-3-Clause) 의 원문은 "환경 설정 > 라이선스"
|
||||
- qr 3.0.2 (Wi-Fi 공유 접속 QR) BSD-3-Clause https://pub.dev/packages/qr
|
||||
Copyright 2014, the Dart QR project authors.
|
||||
- Microsoft Visual C++ 런타임 (msvcp140.dll, vcruntime140*.dll)
|
||||
Microsoft 재배포 가능 파일 (Visual Studio 라이선스의 Distributable Code)
|
||||
- 그 밖의 Dart 패키지 라이선스: 프로그램의 "환경 설정 > 라이선스" 화면
|
||||
|
|
|
|||
|
|
@ -36,8 +36,6 @@ JJ_MKVMaker (Android) - 포함된 오픈 소스 구성 요소 및 라이선스
|
|||
함께: asn-one 0.6.0 (Apache-2.0) · Bouncy Castle bcprov 1.85.2 (MIT) · SLF4J API 2.0.18 (MIT) · MBassador 1.3.2 (MIT).
|
||||
원문: 앱 안 "오픈 소스 라이선스" 의 smbj (SMBJ_LICENSES.txt - Apache License 2.0 전문 포함)
|
||||
Copyright (c) 2022 xuty. 함께 쓰는 pointycastle (MIT) · pinenacl (MIT) · asn1lib (BSD-3-Clause) 의 원문은 [패키지 라이선스]
|
||||
- qr 3.0.2 (Wi-Fi 공유 접속 QR) BSD-3-Clause https://pub.dev/packages/qr
|
||||
Copyright 2014, the Dart QR project authors.
|
||||
- charset (Apache-2.0) · cp949_codec (BSD) · 그 밖의 Dart 패키지: [패키지 라이선스]
|
||||
|
||||
[앱에 들어 있는 별도 실행 파일 · 라이브러리]
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import 'package:jj_mkvmaker/core/languages.dart';
|
|||
import 'package:jj_mkvmaker/core/models.dart';
|
||||
import 'package:jj_mkvmaker/services/model_store.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 긴 영상 (JJ_LONG_VIDEO) 의 음성인식 도중 [작업 취소] → 몇 초 안에 멈춘다 (끝까지 기다리지 않음)
|
||||
void main() {
|
||||
|
|
@ -16,7 +15,6 @@ void main() {
|
|||
testWidgets('음성인식 중 취소', (tester) async {
|
||||
final video = Platform.environment['JJ_LONG_VIDEO'];
|
||||
if (video == null || !File(video).existsSync()) return markTestSkipped('JJ_LONG_VIDEO 없음');
|
||||
appTempInto(testTempDir('jj_aitmp_')); // 앱 임시 폴더 (jj_mkvmaker) 를 시험 폴더로 - 끝에 지움 (관리자 지시 10/20)
|
||||
final c = AppController(PlatformServices.create());
|
||||
await tester.runAsync(() async {
|
||||
await c.init();
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import 'package:jj_mkvmaker/core/languages.dart';
|
|||
import 'package:jj_mkvmaker/core/models.dart';
|
||||
import 'package:jj_mkvmaker/core/output_paths.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 실제 Whisper + NLLB 로 AI 자막 → MKV (모델이 JJ_MKVMAKER_MODELS 에 있어야 함)
|
||||
|
|
@ -17,8 +16,7 @@ void main() {
|
|||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('영어 음성 영상 → AI 자막(ko/en/ja) → MKV', (tester) async {
|
||||
final dir = testTempDir('jj_e2e_');
|
||||
appTempInto(dir); // 앱 임시 폴더 (jj_mkvmaker) 도 이 아래로 - 끝에 함께 지움 (관리자 지시 10/20)
|
||||
final dir = Directory.systemTemp.createTempSync('jj_e2e_');
|
||||
final video = p.join(dir.path, '영어 강의.mp4');
|
||||
final speech = p.join(Directory.current.path, 'test_assets', 'speech_en.wav');
|
||||
final r = await Process.run('ffmpeg', [
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import 'package:jj_mkvmaker/core/models.dart';
|
|||
import 'package:jj_mkvmaker/core/output_paths.dart';
|
||||
import 'package:jj_mkvmaker/services/model_store.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 긴 실제 영상 (JJ_LONG_VIDEO) 으로 AI 자막: 원어 자동 → [JJ_LONG_TARGETS] (기본 ja,en).
|
||||
/// 번역이 제대로 됐는지 줄 단위로 센다 (영어 자막에 일본어가 남은 줄 · 빈 줄 · 같은 말 되풀이).
|
||||
|
|
@ -19,7 +18,6 @@ void main() {
|
|||
final video = Platform.environment['JJ_LONG_VIDEO'];
|
||||
if (video == null || !File(video).existsSync()) return markTestSkipped('JJ_LONG_VIDEO 없음');
|
||||
final targets = (Platform.environment['JJ_LONG_TARGETS'] ?? 'ja,en').split(',');
|
||||
appTempInto(testTempDir('jj_aitmp_')); // 앱 임시 폴더 (jj_mkvmaker) 를 시험 폴더로 - 끝에 지움 (관리자 지시 10/20)
|
||||
final c = AppController(PlatformServices.create());
|
||||
final sw = Stopwatch()..start();
|
||||
await tester.runAsync(() async {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ 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/browser_page.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 실제 Edge(WebView2): MKV 화면 ↔ 브라우저를 여러 번 오가도 보던 페이지를 다시 읽지 않고 그대로 이어진다
|
||||
|
|
@ -28,7 +27,7 @@ void main() {
|
|||
await req.response.close();
|
||||
});
|
||||
final url = 'http://127.0.0.1:${server.port}/page';
|
||||
final dataDir = testTempDir('jj_keep_', heldByApp: true).path;
|
||||
final dataDir = Directory.systemTemp.createTempSync('jj_keep_').path;
|
||||
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
|||
import 'package:jj_mkvmaker/platform/windows/ytdlp_backend.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/browser_page.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 실제 Edge(WebView2): 페이지 열기 · 동영상 감지 · 쿠키 저장 → yt-dlp 가 같은 쿠키를 읽는지
|
||||
|
|
@ -32,7 +31,7 @@ void main() {
|
|||
await req.response.close();
|
||||
});
|
||||
final url = 'http://127.0.0.1:${server.port}/';
|
||||
final dataDir = testTempDir('jj_webview_', heldByApp: true).path;
|
||||
final dataDir = Directory.systemTemp.createTempSync('jj_webview_').path;
|
||||
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings.webViewDataDir = dataDir;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ 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/browser_page.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 실제 망 (Windows): DNS 강제 (8.8.8.8) 상태에서 Chrome 엔진 (CEF) 이 앱 안 프록시의 인증 (비밀값) 에 답해 페이지를 여는지.
|
||||
|
|
@ -34,7 +33,7 @@ void main() {
|
|||
}
|
||||
CefRuntime.rememberStartState();
|
||||
expect(CefRuntime.readyThisRun, isTrue);
|
||||
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_cef_real_', heldByApp: true).path;
|
||||
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? Directory.systemTemp.createTempSync('jj_cef_real_').path;
|
||||
Directory(data).createSync(recursive: true);
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/com_guard.dart';
|
||||
import 'package:jj_mkvmaker/ui/browser_page.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 실제 Edge(WebView2): 다른 구성 요소가 COM 준비 상태를 풀어 버린 상황을 만들고
|
||||
/// (브라우저가 "CoInitialize 가 호출되지 않았습니다" 로 안 뜨던 문제) 그래도 브라우저 환경이 만들어지는지
|
||||
|
|
@ -25,8 +26,7 @@ void main() {
|
|||
for (var i = 0; i < 8; i++) {
|
||||
ComGuard.releaseForTest();
|
||||
}
|
||||
// Edge 가 앱이 끝날 때까지 사용자 데이터 폴더를 잡는다 (test_temp_check 가 찾음, 관리자 지시 10/20)
|
||||
final dir = testTempDir('jj_com_', heldByApp: true).path;
|
||||
final dir = Directory.systemTemp.createTempSync('jj_com_').path;
|
||||
final raw = await tester.runAsync<Object?>(() async {
|
||||
try {
|
||||
await WebViewEnvironment.create(settings: WebViewEnvironmentSettings(userDataFolder: '${dir}_raw'));
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ 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/browser_page.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 250 실제 엔진 (Windows Edge WebView2): DNS 강제 서버에 닿지 않으면 (TEST-NET 192.0.2.1) 엔진의 오류 화면
|
||||
|
|
@ -21,7 +20,7 @@ void main() {
|
|||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('250: 닿지 않는 DNS → 브라우저에 앱 덮개 (Edge 실제)', (tester) async {
|
||||
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_dns_block_', heldByApp: true).path;
|
||||
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? Directory.systemTemp.createTempSync('jj_dns_block_').path;
|
||||
Directory(data).createSync(recursive: true);
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import 'package:jj_mkvmaker/platform/windows/desktop_shell.dart';
|
|||
import 'package:jj_mkvmaker/services/media_player.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 실제 mpv 플레이어 + 창 모드
|
||||
void main() {
|
||||
|
|
@ -17,7 +16,7 @@ void main() {
|
|||
|
||||
testWidgets('재생 목록 · 자막 트랙 · 음량 · 전체 화면 · 팝업 · 창 크기', (tester) async {
|
||||
MediaKitPreviewPlayer.ensureInitialized();
|
||||
final dir = testTempDir('jj_fp_');
|
||||
final dir = Directory.systemTemp.createTempSync('jj_fp_');
|
||||
final srt = p.join(dir.path, 's.srt');
|
||||
File(srt).writeAsStringSync('1\n00:00:00,000 --> 00:00:05,000\n내장 자막\n');
|
||||
final ext = p.join(dir.path, 'ep1_ko.srt');
|
||||
|
|
|
|||
|
|
@ -1,201 +0,0 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/download_manager.dart';
|
||||
import 'package:jj_mkvmaker/app/net_guard.dart';
|
||||
import 'package:jj_mkvmaker/core/download_detect.dart' show DownloadKind;
|
||||
import 'package:jj_mkvmaker/platform/windows/aria2_backend.dart';
|
||||
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/downloader.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/downloads_page.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 사용자 질문 (10/20) "마그넷 테스트 된 건가" - 지금까지는 단위 시험뿐이었다. 실제 마그넷을 앱의 다운로드 관리자 (aria2c) 로 받는다.
|
||||
/// 공개된 합법 배포 파일: Debian 공식 netinst ISO 의 토렌트 (cdimage.debian.org). 정보 해시는 공식 .torrent 에서 계산해
|
||||
/// 마그넷을 만들고 (판이 바뀌어도 맞게), 끝나면 공식 SHA256SUMS 와 맞춘다.
|
||||
/// - DNS 시스템: 끝까지 받기 · 진행률 · 속도 · 일시 정지 / 재개 · SHA256
|
||||
/// - DNS 강제 (8.8.8.8 · 8.8.4.4, 앱 안 프록시): 받기 시작 · 진행률 · 일시 정지 / 재개 · 취소 (받던 파일 지움)
|
||||
/// 인터넷 · 디스크 (약 800MB) 를 쓰므로 JJ_REAL_MAGNET=1 일 때만:
|
||||
/// flutter test integration_test\magnet_real_test.dart -d windows (PowerShell: $env:JJ_REAL_MAGNET='1')
|
||||
/// 받은 파일은 시험 폴더 (%TEMP%\jj_magnet_*) 에 두고 끝나면 지운다.
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
const base = 'https://cdimage.debian.org/debian-cd/current/amd64/bt-cd/';
|
||||
|
||||
for (final forced in [false, true]) {
|
||||
testWidgets('마그넷 실제 받기 (DNS ${forced ? '강제 8.8.8.8' : '시스템'})', (tester) async {
|
||||
if (Platform.environment['JJ_REAL_MAGNET'] != '1') return markTestSkipped('JJ_REAL_MAGNET=1 일 때만 (인터넷 · 약 800MB)');
|
||||
final aria2c = p.join(Directory.current.path, 'third_party', 'tools', 'windows', 'aria2c.exe');
|
||||
expect(File(aria2c).existsSync(), isTrue, reason: '앱에 든 aria2c (tool\\fetch_tools.ps1)');
|
||||
final dir = testTempDir('jj_magnet_');
|
||||
void log(String s) => debugPrint('MAGNET ${forced ? 'DNS강제' : 'DNS시스템'} ${DateTime.now().toIso8601String().substring(11, 19)} $s');
|
||||
|
||||
// 공식 토렌트 · SHA256 (판 이름은 목록에서)
|
||||
late Uint8List torrent;
|
||||
late String isoName, want;
|
||||
await tester.runAsync(() async {
|
||||
final http = HttpClient();
|
||||
Future<List<int>> get(String url) async {
|
||||
final res = await (await http.getUrl(Uri.parse(url))).close();
|
||||
expect(res.statusCode, 200, reason: url);
|
||||
return [for (final c in await res.toList()) ...c];
|
||||
}
|
||||
|
||||
final index = utf8.decode(await get(base));
|
||||
final name = RegExp(r'href="(debian-[0-9.]+-amd64-netinst\.iso)\.torrent"').firstMatch(index)![1]!;
|
||||
isoName = name;
|
||||
torrent = Uint8List.fromList(await get('$base$name.torrent'));
|
||||
final sums = utf8.decode(await get('${base}SHA256SUMS'));
|
||||
want = RegExp('^([0-9a-f]{64})\\s+${RegExp.escape(name)}\$', multiLine: true).firstMatch(sums)![1]!;
|
||||
http.close();
|
||||
});
|
||||
final (infoHash, announce) = _torrentInfo(torrent);
|
||||
final magnet = 'magnet:?xt=urn:btih:$infoHash&dn=${Uri.encodeComponent(isoName)}&tr=${Uri.encodeComponent(announce)}';
|
||||
log('$isoName · $infoHash · 공식 SHA256 $want');
|
||||
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
..downloadRoot = dir.path
|
||||
..dnsMode = forced ? 'custom' : 'system'
|
||||
..dnsPrimary = '8.8.8.8'
|
||||
..dnsSecondary = '8.8.4.4';
|
||||
final g = NetGuard.instance
|
||||
..config = (() => c.settings.dnsConfig)
|
||||
..log = log;
|
||||
await tester.runAsync(g.ensure);
|
||||
addTearDown(() async {
|
||||
c.settings.dnsMode = 'system';
|
||||
await g.ensure();
|
||||
});
|
||||
final d = DownloadManager(backends: [Aria2Backend(aria2c: aria2c)], settings: () => c.settings);
|
||||
await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d)));
|
||||
|
||||
Future<void> wait(int ms) async {
|
||||
for (var i = 0; i < ms ~/ 250; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 250)));
|
||||
await tester.pump();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> until(bool Function() ok, {required Duration within}) async {
|
||||
final end = DateTime.now().add(within);
|
||||
while (!ok() && DateTime.now().isBefore(end)) {
|
||||
await wait(1000);
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
|
||||
// 받기 시작 (메타데이터 → 실제 받기) · 진행률 · 속도
|
||||
final t = d.add(magnet);
|
||||
expect(t, isNotNull);
|
||||
expect(t!.kind, DownloadKind.torrent);
|
||||
final speeds = <String>{};
|
||||
final started = await until(() {
|
||||
if (t.speed.isNotEmpty) speeds.add(t.speed);
|
||||
return (t.receivedBytes ?? 0) >= 8 << 20 || t.state == DownloadState.failed;
|
||||
}, within: const Duration(minutes: 5));
|
||||
log('시작: ${t.state} ${t.receivedBytes}/${t.totalBytes} 속도 $speeds 오류 ${t.error}');
|
||||
expect(started && t.state == DownloadState.downloading, isTrue, reason: '8MB 를 5분 안에 받지 못함: ${t.state} ${t.error}');
|
||||
expect(t.title, isoName, reason: '메타데이터를 받으면 이름이 바뀜');
|
||||
expect(t.progress, greaterThan(0));
|
||||
expect(t.totalBytes, greaterThan(100 << 20));
|
||||
expect(speeds, isNotEmpty, reason: '속도가 보임');
|
||||
expect(find.text(isoName), findsWidgets, reason: '다운로드 화면 줄');
|
||||
|
||||
// 일시 정지: 받은 크기가 늘지 않는다
|
||||
await tester.runAsync(() => d.pause([t]));
|
||||
await wait(3000);
|
||||
final paused = t.receivedBytes ?? 0;
|
||||
final onDisk = t.files.where((f) => File(f).existsSync()).map((f) => File(f).lengthSync()).fold(0, (a, b) => a + b);
|
||||
await wait(5000);
|
||||
log('일시 정지: ${t.state} $paused');
|
||||
expect(t.state, DownloadState.paused);
|
||||
expect(t.receivedBytes ?? 0, paused);
|
||||
expect(t.files.where((f) => File(f).existsSync()).map((f) => File(f).lengthSync()).fold(0, (a, b) => a + b), onDisk,
|
||||
reason: '멈춘 동안 파일이 커지지 않음');
|
||||
|
||||
// 재개: 이어서 받는다 (처음부터가 아니라)
|
||||
await tester.runAsync(() => d.resume([t]));
|
||||
final resumed = await until(() => (t.receivedBytes ?? 0) >= paused + (4 << 20), within: const Duration(minutes: 3));
|
||||
log('재개: ${t.state} ${t.receivedBytes}');
|
||||
expect(resumed, isTrue, reason: '재개 뒤 4MB 더 받지 못함');
|
||||
|
||||
if (!forced) {
|
||||
// 끝까지 · 공식 SHA256
|
||||
final done = await until(() => t.state == DownloadState.done || t.state == DownloadState.failed,
|
||||
within: const Duration(minutes: 30));
|
||||
log('끝: ${t.state} ${t.files}');
|
||||
expect(done && t.state == DownloadState.done, isTrue, reason: '${t.state} ${t.error}');
|
||||
final iso = t.files.firstWhere((f) => f.endsWith(isoName));
|
||||
final got = await tester.runAsync(() async => '${await sha256.bind(File(iso).openRead()).first}');
|
||||
log('SHA256 $got');
|
||||
expect(got, want, reason: '공식 SHA256SUMS 와 같음');
|
||||
} else {
|
||||
// 취소: 받던 파일 (와 .aria2) 이 지워진다
|
||||
final files = [...t.files];
|
||||
await tester.runAsync(() => d.cancel([t]));
|
||||
await wait(2000);
|
||||
log('취소: ${t.state} 남은 파일 ${files.where((f) => File(f).existsSync() || File('$f.aria2').existsSync()).toList()}');
|
||||
expect(t.state, DownloadState.cancelled);
|
||||
for (final f in files) {
|
||||
expect(File(f).existsSync() || File('$f.aria2').existsSync(), isFalse, reason: '$f 지워짐');
|
||||
}
|
||||
}
|
||||
await tester.runAsync(d.shutdown);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await wait(500);
|
||||
}, timeout: const Timeout(Duration(minutes: 45)));
|
||||
}
|
||||
}
|
||||
|
||||
/// .torrent (bencode) 에서 정보 해시 (info 사전 바이트의 SHA-1) 와 첫 tracker
|
||||
(String, String) _torrentInfo(Uint8List b) {
|
||||
var i = 0;
|
||||
String str() {
|
||||
final colon = b.indexOf(58, i); // ':'
|
||||
final n = int.parse(ascii.decode(b.sublist(i, colon)));
|
||||
final s = latin1.decode(b.sublist(colon + 1, colon + 1 + n));
|
||||
i = colon + 1 + n;
|
||||
return s;
|
||||
}
|
||||
|
||||
void skip() {
|
||||
final c = b[i];
|
||||
if (c == 0x69) {
|
||||
i = b.indexOf(0x65, i) + 1; // i…e
|
||||
} else if (c == 0x6c || c == 0x64) {
|
||||
i++;
|
||||
while (b[i] != 0x65) {
|
||||
if (c == 0x64) str();
|
||||
skip();
|
||||
}
|
||||
i++;
|
||||
} else {
|
||||
str();
|
||||
}
|
||||
}
|
||||
|
||||
String? hash, announce;
|
||||
i = 1; // 맨 위 사전 'd'
|
||||
while (b[i] != 0x65) {
|
||||
final key = str();
|
||||
final start = i;
|
||||
if (key == 'announce') {
|
||||
announce = utf8.decode(latin1.encode(str()));
|
||||
continue;
|
||||
}
|
||||
skip();
|
||||
if (key == 'info') hash = '${sha1.convert(b.sublist(start, i))}';
|
||||
}
|
||||
return (hash!, announce ?? '');
|
||||
}
|
||||
|
|
@ -13,7 +13,6 @@ import 'package:jj_mkvmaker/services/platform_services.dart';
|
|||
import 'package:jj_mkvmaker/ui/monitor_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/schedule_editor.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 모니터링 화면 (복사 진행 중 · lsync · 일정 격자) 을 실제 글꼴로 그려 PNG 로 (JJ_SHOT_DIR). 화면 배치 확인용.
|
||||
|
|
@ -22,7 +21,7 @@ void main() {
|
|||
|
||||
testWidgets('모니터링 화면 그림', (tester) async {
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
|
||||
final tmp = testTempDir('jj_monshot_');
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_monshot_');
|
||||
final src = p.join(tmp.path, '영상 원본'), dst = p.join(tmp.path, '백업');
|
||||
for (var i = 0; i < 6; i++) {
|
||||
File(p.join(src, i < 3 ? 'A' : 'B', 'clip$i.mkv'))
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import 'package:flutter_test/flutter_test.dart';
|
|||
import 'package:image/image.dart' as img;
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/core/reader_sources.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 실제 pdfium: 그림 → PDF, 열기 · 그리기, 페이지 지우기 · 그림 넣기 · 저장
|
||||
|
|
@ -13,7 +12,8 @@ void main() {
|
|||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('PDF: 만들기 · 열기 · 지우기 · 넣기 · 저장', (tester) async {
|
||||
final dir = testTempDir('jj_pdf_');
|
||||
final dir = Directory.systemTemp.createTempSync('jj_pdf_');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
String image(String name, int w, int h, {bool jpg = false}) {
|
||||
final im = img.Image(width: w, height: h);
|
||||
img.fill(im, color: img.ColorRgb8(200, 50, 50));
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/platform/common/media_kit_player.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 실제 Windows 앱에서 libmpv 재생 확인
|
||||
|
|
@ -15,7 +14,7 @@ void main() {
|
|||
testWidgets('media_kit: 영상 열기 · 재생 · 이동', (tester) async {
|
||||
MediaKitPreviewPlayer.ensureInitialized();
|
||||
|
||||
final dir = testTempDir('jj_play_');
|
||||
final dir = Directory.systemTemp.createTempSync('jj_play_');
|
||||
final video = p.join(dir.path, '재생 테스트.mp4');
|
||||
final r = await Process.run('ffmpeg', [
|
||||
'-y', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=25',
|
||||
|
|
|
|||
|
|
@ -11,9 +11,7 @@ import 'package:jj_mkvmaker/app/settings.dart';
|
|||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/explorer_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/monitor_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// Rsync 화면을 실제 rsync (앱에 들어 있는 것) 로: → 한 방향, ⇄ 양쪽 (-u), 모니터링에 남는지. 화면은 JJ_SHOT_DIR 에 PNG.
|
||||
|
|
@ -22,7 +20,7 @@ void main() {
|
|||
|
||||
testWidgets('Rsync 화면: → · ⇄ 실제 실행', (tester) async {
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
|
||||
final tmp = testTempDir('jj_rsyncpage_');
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_rsyncpage_');
|
||||
final a = p.join(tmp.path, '왼쪽 폴더'), b = p.join(tmp.path, 'right');
|
||||
File(p.join(a, 'sub', '한글 파일.txt'))
|
||||
..createSync(recursive: true)
|
||||
|
|
@ -108,79 +106,9 @@ void main() {
|
|||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
testWidgets('177: 옵션에 --exclude 가 있으면 실행 전 비교 목록도 실제 rsync 와 같게 (뺀 것 · 받는 쪽의 뺀 것은 지우지 않음)', (tester) async {
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_rsyncexcl_');
|
||||
final a = p.join(tmp.path, 'exSrc'), b = p.join(tmp.path, 'exDst');
|
||||
void f(String dir, String rel) => File(p.join(dir, rel))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync(rel);
|
||||
for (final r in ['a.txt', 'b.log', 'build/x.o', 'sub/c.txt', 'sub/deep/d.txt']) {
|
||||
f(a, r);
|
||||
}
|
||||
for (final r in ['extra.txt', 'keep.log', 'build/old.o']) {
|
||||
f(b, r);
|
||||
}
|
||||
await tester.runAsync(findBundledRsync);
|
||||
final c = AppController(PlatformServices.create());
|
||||
c.settings
|
||||
..rsyncPaths = [tmp.path, tmp.path]
|
||||
..rsyncOptions = '-avPog --delete --exclude=*.log --exclude=build/ --exclude=/sub/deep/';
|
||||
final key = GlobalKey();
|
||||
tester.view.physicalSize = const Size(1500, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
Future<void> wait(int ms) async {
|
||||
for (var i = 0; i < ms ~/ 100; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));
|
||||
await tester.pump();
|
||||
}
|
||||
}
|
||||
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: buildTheme(),
|
||||
builder: (_, child) => RepaintBoundary(key: key, child: child),
|
||||
home: ExplorerPage(c: c, rsync: true),
|
||||
));
|
||||
await wait(2500);
|
||||
await tester.tap(find.text('exSrc').first);
|
||||
await wait(600);
|
||||
await tester.tap(find.text('exDst').last);
|
||||
await wait(600);
|
||||
await tester.tap(find.text('좌 → 우'));
|
||||
await wait(2500); // 비교
|
||||
Finder inDialog(String t) => find.descendant(of: find.byType(Dialog), matching: find.textContaining(t));
|
||||
final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final image = await tester.runAsync(() => boundary.toImage());
|
||||
final bytes = await tester.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png));
|
||||
File('$out/rsync_exclude_confirm.png').writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
// 비교 목록: 보낼 것 · 지울 것만 (뺀 것은 없음)
|
||||
expect(inDialog('a.txt'), findsWidgets);
|
||||
expect(inDialog('c.txt'), findsWidgets);
|
||||
expect(inDialog('extra.txt'), findsWidgets, reason: '--delete 로 지워짐');
|
||||
for (final t in ['b.log', 'keep.log', 'x.o', 'old.o', 'd.txt']) {
|
||||
expect(inDialog(t), findsNothing, reason: '$t 는 걸러짐 (실제 rsync 도 건드리지 않음)');
|
||||
}
|
||||
await tester.tap(find.text('실행'));
|
||||
for (var i = 0; i < 100 && File(p.join(b, 'extra.txt')).existsSync(); i++) {
|
||||
await wait(200);
|
||||
}
|
||||
await wait(1500);
|
||||
// 실제 rsync 결과가 비교 목록과 같다
|
||||
expect(File(p.join(b, 'a.txt')).existsSync(), isTrue);
|
||||
expect(File(p.join(b, 'sub', 'c.txt')).existsSync(), isTrue);
|
||||
expect(File(p.join(b, 'extra.txt')).existsSync(), isFalse);
|
||||
expect(File(p.join(b, 'b.log')).existsSync(), isFalse);
|
||||
expect(File(p.join(b, 'keep.log')).existsSync(), isTrue, reason: '받는 쪽의 뺀 것은 지우지 않음');
|
||||
expect(File(p.join(b, 'build', 'old.o')).existsSync(), isTrue);
|
||||
expect(File(p.join(b, 'build', 'x.o')).existsSync(), isFalse);
|
||||
expect(Directory(p.join(b, 'sub', 'deep')).existsSync(), isFalse);
|
||||
tester.view.reset();
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
testWidgets('원본 파일 지우기 (--remove-source-files) · 빈 폴더 정리 · lsync 최종 정리', (tester) async {
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
|
||||
final tmp = testTempDir('jj_rsyncmove_');
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_rsyncmove_');
|
||||
final a = p.join(tmp.path, 'srcA'), b = p.join(tmp.path, 'dstB');
|
||||
final cDir = p.join(tmp.path, 'liveC'), d = p.join(tmp.path, 'liveD');
|
||||
for (final (dir, name) in [(a, 'x'), (cDir, 'y')]) {
|
||||
|
|
@ -228,8 +156,7 @@ void main() {
|
|||
await tap(find.text('srcA').first);
|
||||
await tap(find.text('dstB').last);
|
||||
await tap(find.text('좌 → 우'));
|
||||
// 옵션 이름은 줄바꿈되지 않게 묶여 그려진다 (rsync_narrow dfed3fa) - 화면에 그려지는 글 그대로 찾는다
|
||||
await tap(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')));
|
||||
await tap(find.text('원본 파일 지우기 (--remove-source-files)'));
|
||||
await tap(find.text('빈 폴더 지움 · 원본 폴더도 지움'));
|
||||
await shot('rsync_move_confirm');
|
||||
await tap(find.widgetWithText(FilledButton, '이동'));
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import 'dart:io';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/services/secret_store.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 54: 이 기기의 안전 저장소 (Windows DPAPI · Android Keystore) 에 실제로 쓰고 읽고 지운다.
|
||||
/// 시험용 열쇠 하나만 쓰고 끝에 지운다 (사용자가 저장한 값은 건드리지 않음).
|
||||
|
|
@ -14,7 +13,7 @@ void main() {
|
|||
Directory? tmp;
|
||||
SecretStore store() {
|
||||
if (!Platform.isWindows) return SecretStore.platform();
|
||||
return SecretStore.dpapiAt((tmp ??= testTempDir('jj_secretit_')).path);
|
||||
return SecretStore.dpapiAt((tmp ??= Directory.systemTemp.createTempSync('jj_secretit_')).path);
|
||||
}
|
||||
|
||||
tearDownAll(() {
|
||||
|
|
|
|||
|
|
@ -14,10 +14,6 @@ void main() {
|
|||
final steps = File(p.join(Directory.systemTemp.path, 'jj_shell_steps.txt'));
|
||||
if (steps.existsSync()) steps.deleteSync();
|
||||
void mark(String s) => steps.writeAsStringSync('$s\n', mode: FileMode.append, flush: true);
|
||||
// 관리자 지시 (10/20): 끝까지 돌면 단계 기록을 지운다 (앱이 도중에 꺼졌을 때만 남아 어디까지 갔는지 보인다)
|
||||
tearDownAll(() {
|
||||
if (steps.existsSync()) steps.deleteSync();
|
||||
});
|
||||
|
||||
testWidgets('DesktopShell', (tester) async {
|
||||
mark('start');
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||
|
||||
/// 관리자 지시 (10/19, 28 이 찾음): 통합 시험 · 캡처가 %TEMP%\jj_* 시험 폴더를 남겼다 - 시험 끝 (실패해도) 에 지운다.
|
||||
/// [base] 가 있으면 그 아래 (짧은 경로가 필요한 캡처). 반드시 시험 (testWidgets · test) 안에서 부른다 (addTearDown).
|
||||
/// [heldByApp]: 앱이 끝날 때까지 놓지 않는 폴더 (Edge 사용자 데이터 - Windows 판 플러그인에는 환경 닫기가 없다).
|
||||
/// 이름 앞에 jj_held_ 를 붙이고, tool/test_temp_check.ps1 은 이것을 실패로 세지 않고 앱이 끝난 뒤 실행 폴더와 함께 지운다
|
||||
Directory testTempDir(String prefix, {String? base, bool heldByApp = false}) {
|
||||
final root = base == null || base.isEmpty ? Directory.systemTemp : (Directory(base)..createSync(recursive: true));
|
||||
final dir = root.createTempSync(heldByApp ? 'jj_held_$prefix' : prefix);
|
||||
addTearDown(() => deleteTestTemp(dir));
|
||||
if (base == null || base.isEmpty) expectInTaggedTemp(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/// 관리자 지시 (10/20): 앱 임시 폴더 ([DesktopStorageService.tempDirectory] = %TEMP%\jj_mkvmaker) 를 이 시험 폴더 아래로 돌린다
|
||||
/// (ai_e2e 가 남겨 test_temp_check 가 exit 2). 시험 끝에 기준을 되돌리고, 폴더는 [testTempDir] 의 정리가 함께 지운다
|
||||
/// (addTearDown 은 나중에 넣은 것부터 돌아 앱 기준을 먼저 되돌린다). 사용자 %TEMP%\jj_mkvmaker 는 건드리지 않는다
|
||||
void appTempInto(Directory dir) {
|
||||
final before = DesktopStorageService.tempRoot;
|
||||
DesktopStorageService.tempRoot = dir.path;
|
||||
addTearDown(() => DesktopStorageService.tempRoot = before);
|
||||
}
|
||||
|
||||
/// 관리자 지시 (10/20): tool/test_temp_check.ps1 이 TEMP · TMP 를 실행 폴더 (%TEMP%\t_jjrun_<표>) 로 돌리고 표를 JJ_TEST_TMP_TAG 로 넘긴다.
|
||||
/// 표가 있는데 임시 폴더가 그 밖에 생기면 (환경 변수가 시험 · 앱까지 오지 않음) 다른 세션 시험과 섞이므로 시험을 실패시킨다
|
||||
void expectInTaggedTemp(Directory dir) {
|
||||
final tag = Platform.environment['JJ_TEST_TMP_TAG'] ?? '';
|
||||
if (tag.isEmpty) return;
|
||||
expect(dir.absolute.path.toLowerCase(), contains('t_jjrun_$tag'.toLowerCase()),
|
||||
reason: 'TEMP 가 시험 실행 폴더로 오지 않음 (JJ_TEST_TMP_TAG=$tag)');
|
||||
}
|
||||
|
||||
/// 시험 폴더 지우기 - Windows 에서 앱 · WebView · 백신이 파일을 잠깐 잡고 있으면 몇 번 다시 해 본다 (기다림은 실제 시간: 가짜 시계 밖).
|
||||
/// 긴 경로는 \\?\ 로. 끝내 못 지우면 알리기만 하고 시험은 실패시키지 않는다 (tool/test_temp_check.ps1 이 남은 것을 잡는다)
|
||||
void deleteTestTemp(Directory dir) {
|
||||
final path = dir.absolute.path;
|
||||
final target = Platform.isWindows && !path.startsWith(r'\\?\') ? Directory('\\\\?\\$path') : dir;
|
||||
for (var i = 0; i < 20; i++) {
|
||||
try {
|
||||
if (!target.existsSync()) return;
|
||||
target.deleteSync(recursive: true);
|
||||
return;
|
||||
} on FileSystemException {
|
||||
sleep(const Duration(milliseconds: 250));
|
||||
}
|
||||
}
|
||||
stderr.writeln('시험 임시 폴더를 지우지 못함: $path');
|
||||
}
|
||||
|
|
@ -17,7 +17,6 @@ import 'package:jj_mkvmaker/services/platform_services.dart';
|
|||
import 'package:jj_mkvmaker/ui/app_actions.dart';
|
||||
import 'package:jj_mkvmaker/ui/browser_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/tor_page.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 실제 망 (Windows): DNS 강제 (8.8.8.8) 로 앱의 HTTP · 일반 브라우저 (Edge) 가 이름을 푸는지, 닿지 않는 DNS 는 실패로 알리는지,
|
||||
|
|
@ -27,7 +26,7 @@ void main() {
|
|||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('DNS 강제 · Tor 화면 실제 연결', (tester) async {
|
||||
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_tor_real_', heldByApp: true).path;
|
||||
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? Directory.systemTemp.createTempSync('jj_tor_real_').path;
|
||||
Directory(data).createSync(recursive: true);
|
||||
ComponentStore.dataDirectory = data;
|
||||
ComponentStore.shared = ComponentStore(data);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import 'package:jj_mkvmaker/core/models.dart';
|
|||
import 'package:jj_mkvmaker/core/output_paths.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/browser_page.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 실제 NLLB + 실제 Edge(WebView2): 브라우저를 보면서 영어 자막 → 한국어 번역 → MKV 를
|
||||
|
|
@ -20,7 +19,7 @@ void main() {
|
|||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('브라우저 + 작업 현황: 영어 자막 → 한국어 번역 → MKV (대기열)', (tester) async {
|
||||
final dir = testTempDir('jj_split_');
|
||||
final dir = Directory.systemTemp.createTempSync('jj_split_');
|
||||
final video = p.join(dir.path, 'movie.mp4');
|
||||
final r = await Process.run('ffmpeg', [
|
||||
'-y', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10', '-f', 'lavfi', '-i', 'sine',
|
||||
|
|
@ -43,7 +42,7 @@ void main() {
|
|||
await c.init();
|
||||
await c.addVideos([video]);
|
||||
});
|
||||
c.settings.webViewDataDir = testTempDir('jj_webview_', heldByApp: true).path;
|
||||
c.settings.webViewDataDir = Directory.systemTemp.createTempSync('jj_webview_').path;
|
||||
final v = c.videos.single;
|
||||
// 같은 폴더의 movie.en.srt 가 자동으로 잡힘
|
||||
final s = v.subtitles.firstWhere((x) => x.path != null && p.equals(x.path!, en));
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import 'package:jj_mkvmaker/core/output_paths.dart';
|
|||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:jj_mkvmaker/ui/video_adjust_dialog.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 실제 FFmpeg: 가로 영상 → 화면 · 색 보정 창 미리보기 → 세로 9:16 + 채도 MKV 만들기
|
||||
|
|
@ -20,8 +19,7 @@ void main() {
|
|||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('화면 · 색 보정: 미리보기와 세로 9:16 MKV', (tester) async {
|
||||
final dir = testTempDir('jj_adj_e2e_');
|
||||
appTempInto(dir); // 앱 임시 폴더 (jj_mkvmaker) 도 이 아래로 - 끝에 함께 지움 (관리자 지시 10/20)
|
||||
final dir = Directory.systemTemp.createTempSync('jj_adj_e2e_');
|
||||
final video = p.join(dir.path, '가로 영상.mp4');
|
||||
final ffmpeg = p.join(Directory.current.path, 'third_party', 'ffmpeg', 'windows', 'ffmpeg.exe');
|
||||
final r = await Process.run(ffmpeg, [
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ 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/browser_page.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 실제 웹뷰 (Windows: Edge WebView2 · Android: 시스템 WebView) + 실제 Google 번역:
|
||||
|
|
@ -51,7 +50,7 @@ void main() {
|
|||
await req.response.close();
|
||||
});
|
||||
final url = 'http://127.0.0.1:${server.port}/';
|
||||
final dataDir = testTempDir('jj_webview_', heldByApp: true).path;
|
||||
final dataDir = Directory.systemTemp.createTempSync('jj_webview_').path;
|
||||
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import 'package:flutter_test/flutter_test.dart';
|
|||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:whisper_ggml/whisper_ggml.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// whisper.cpp 동작 확인 (스마트 앱 컨트롤·속도)
|
||||
void main() {
|
||||
|
|
@ -15,7 +14,7 @@ void main() {
|
|||
'whisper', 'ggml-base.bin');
|
||||
final assets = p.join(Directory.current.path, 'test_assets');
|
||||
for (final name in ['speech_en.wav', 'speech_ko.wav']) {
|
||||
final tmp = testTempDir('jj_w_');
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_w_');
|
||||
final wav = File(p.join(assets, name)).copySync(p.join(tmp.path, name)).path;
|
||||
final sw = Stopwatch()..start();
|
||||
final r = await tester.runAsync(() => const Whisper(model: WhisperModel.base).transcribe(
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import 'package:jj_mkvmaker/ui/app_actions.dart';
|
|||
import 'package:jj_mkvmaker/ui/browser_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/home_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 66-2 기기 감독 확인용 (Windows): 지금 화면 아이콘 (넓은 · 좁은 화면) · 처음으로 알림 · 브라우저 안내 한 줄을 PNG (JJ_SHOT_DIR) 로.
|
||||
|
|
@ -84,7 +83,9 @@ void main() {
|
|||
..components = ['mkv', 'browser', 'explorer', 'rsync']
|
||||
..navOrder = ['mkv', 'browser', 'explorer', 'rsync'];
|
||||
final base = Platform.environment['JJ_SHOT_TMP'];
|
||||
final dir = testTempDir('jj_shot_662_', base: base);
|
||||
final dir = (base == null || base.isEmpty ? Directory.systemTemp : (Directory(base)..createSync(recursive: true)))
|
||||
.createTempSync('jj_shot_662_');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final bm = BookmarksController(p.join(dir.path, 'bookmarks.json'));
|
||||
t.view.physicalSize = const Size(1280, 800);
|
||||
t.view.devicePixelRatio = 1;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import 'package:path/path.dart' as p;
|
|||
|
||||
import '../test/support/fake_recycle_bin.dart';
|
||||
import '../test/support/recycle_leftovers.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 묶음 3 (데이터 쪽) 기기 감독 확인용 (Windows): 실제 앱 화면을 띄워 누르고 단계마다 PNG (JJ_SHOT_DIR) 로 남긴다.
|
||||
/// 165 (드라이브 · 연결 안 됨) · 48 (같은 이름) · 52 (돌아와도 진행 막대) · 148 (휴지통 되돌리기) · 144 (긴 경로).
|
||||
|
|
@ -24,7 +23,9 @@ void main() {
|
|||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_b3');
|
||||
Directory tempDir(String prefix) {
|
||||
final base = Platform.environment['JJ_SHOT_TMP'];
|
||||
return testTempDir(prefix, base: base);
|
||||
if (base == null || base.isEmpty) return Directory.systemTemp.createTempSync(prefix);
|
||||
Directory(base).createSync(recursive: true);
|
||||
return Directory(base).createTempSync(prefix);
|
||||
}
|
||||
|
||||
final key = GlobalKey();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton, kPrimaryButton, PointerDeviceKind;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
|
@ -14,7 +12,6 @@ import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
|||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/explorer_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 관리자 지시 9 (10/17): 82 끌어 놓기 · 75 마우스 4 · 5 번을 Windows 실제 빌드에서 가짜 포인터로 (실제 마우스 · 키보드는 쓰지 않음).
|
||||
|
|
@ -26,11 +23,10 @@ void main() {
|
|||
late Directory tmp;
|
||||
late String left, right;
|
||||
late AppController c;
|
||||
final shotKey = GlobalKey();
|
||||
|
||||
setUp(() async {
|
||||
await i18n.apply('ko', save: false);
|
||||
tmp = testTempDir('jj_b6input_');
|
||||
tmp = Directory.systemTemp.createTempSync('jj_b6input_');
|
||||
left = p.join(tmp.path, 'left');
|
||||
right = p.join(tmp.path, 'right');
|
||||
File(p.join(left, 'doc.txt'))
|
||||
|
|
@ -73,11 +69,7 @@ void main() {
|
|||
t.view.physicalSize = const Size(1600, 900);
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.pumpWidget(MaterialApp(
|
||||
theme: buildTheme(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
builder: (_, child) => RepaintBoundary(key: shotKey, child: child),
|
||||
home: ExplorerPage(c: c)));
|
||||
await t.pumpWidget(MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: ExplorerPage(c: c)));
|
||||
await until(t, () => find.text('doc.txt').evaluate().isNotEmpty && find.text('r1').evaluate().isNotEmpty);
|
||||
expect(find.text('doc.txt'), findsOneWidget);
|
||||
}
|
||||
|
|
@ -111,61 +103,6 @@ void main() {
|
|||
|
||||
int feedback() => find.byIcon(Icons.drive_file_move_outline).evaluate().length;
|
||||
|
||||
/// 관리자 지시 (10/20): 실패 때의 화면 캡처 (JJ_SHOT_DIR) 와 그때 열린 창 · 단추 · 글 목록
|
||||
Future<void> dump(WidgetTester t, String what) async {
|
||||
final texts = [for (final e in find.byType(Text).evaluate()) (e.widget as Text).data ?? (e.widget as Text).textSpan?.toPlainText() ?? ''];
|
||||
final buttons = [
|
||||
for (final e in find.byWidgetPredicate((w) => w is ButtonStyleButton || w is SnackBarAction).evaluate())
|
||||
'${e.widget.runtimeType}: ${[for (final x in find.descendant(of: find.byWidget(e.widget), matching: find.byType(Text)).evaluate()) (x.widget as Text).data].join(' ')}'
|
||||
];
|
||||
// ignore: avoid_print
|
||||
print('== $what 실패 때: 창 ${find.byType(Dialog).evaluate().length} · 스낵바 ${find.byType(SnackBar).evaluate().length}\n단추: $buttons\n글: $texts');
|
||||
final dir = Platform.environment['JJ_SHOT_DIR'];
|
||||
final b = shotKey.currentContext?.findRenderObject();
|
||||
if (dir == null || dir.isEmpty || b is! RenderRepaintBoundary) return;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
File(p.join(dir, 'b6_input_fail_${what.hashCode.toRadixString(16)}.png'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
/// 관리자 지시 (10/20): [f] 가 보이는 것만이 아니라 정말 눌리는 자리에 올 때까지 기다린 뒤 누른다.
|
||||
/// 스낵바는 아래에서 올라오는 동안 (약 0.25초) 글은 이미 있지만 그 자리를 누르면 아무것도 맞지 않는다 - 예전에는 그때 눌러 [되돌리기] 가 빗나갔다.
|
||||
/// 끝내 눌리지 않으면 캡처 · 목록을 남기고 실패
|
||||
Future<void> tapReady(WidgetTester t, Finder f, String what, {int ms = 8000}) async {
|
||||
final sw = Stopwatch()..start();
|
||||
int? seen;
|
||||
bool within(Object? target, RenderObject box) {
|
||||
for (RenderObject? x = target is RenderObject ? target : null; x != null; x = x.parent) {
|
||||
if (identical(x, box)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < ms ~/ 50; i++) {
|
||||
final e = f.evaluate();
|
||||
if (e.length == 1) {
|
||||
seen ??= sw.elapsedMilliseconds;
|
||||
final box = e.single.renderObject;
|
||||
if (box is RenderBox && box.attached && box.hasSize) {
|
||||
final at = box.localToGlobal(box.size.center(Offset.zero));
|
||||
final hit = HitTestResult();
|
||||
t.binding.hitTestInView(hit, at, t.view.viewId);
|
||||
if (hit.path.any((x) => within(x.target, box))) {
|
||||
// ignore: avoid_print
|
||||
print('누를 수 있음: $what - 보인 뒤 ${sw.elapsedMilliseconds - seen}ms');
|
||||
await t.tapAt(at);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
await t.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
await dump(t, what);
|
||||
fail('$what 을 누를 수 없음 (보인 때: ${seen == null ? '안 보임' : '${seen}ms'})');
|
||||
}
|
||||
|
||||
testWidgets('82: 8px 문턱 · 놓을 곳 강조 · 같은 창은 빨간 테두리 · 묻기 창 [이동] · [되돌리기]', (t) async {
|
||||
await open(t);
|
||||
final doc = t.getCenter(find.text('doc.txt'));
|
||||
|
|
@ -203,15 +140,17 @@ void main() {
|
|||
await until(t, () => find.text('1개 항목을 놓았습니다').evaluate().isNotEmpty);
|
||||
expect(find.text('1개 항목을 놓았습니다'), findsOneWidget);
|
||||
expect(find.widgetWithText(FilledButton, '복사'), findsOneWidget, reason: '처음 고른 단추는 [복사]');
|
||||
await tapReady(t, find.widgetWithText(OutlinedButton, '이동'), '묻기 창 [이동]');
|
||||
await t.tap(find.widgetWithText(OutlinedButton, '이동'));
|
||||
// 지금의 이동 확인 창
|
||||
await tapReady(t, find.widgetWithText(FilledButton, '이동'), '이동 확인 창 [이동]');
|
||||
await until(t, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty);
|
||||
await t.tap(find.widgetWithText(FilledButton, '이동'));
|
||||
await until(t, () => find.text('되돌리기').evaluate().isNotEmpty);
|
||||
expect(File(p.join(right, 'doc.txt')).existsSync(), isTrue);
|
||||
expect(File(p.join(left, 'doc.txt')).existsSync(), isFalse);
|
||||
// 4) [되돌리기] → 원래 자리로 (지금의 이동 확인 창)
|
||||
await tapReady(t, find.text('되돌리기'), '[되돌리기]');
|
||||
await tapReady(t, find.widgetWithText(FilledButton, '이동'), '되돌리기의 이동 확인 창 [이동]');
|
||||
await t.tap(find.text('되돌리기'));
|
||||
await until(t, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty);
|
||||
await t.tap(find.widgetWithText(FilledButton, '이동'));
|
||||
await until(t, () => File(p.join(left, 'doc.txt')).existsSync() && !File(p.join(right, 'doc.txt')).existsSync());
|
||||
expect(File(p.join(left, 'doc.txt')).readAsStringSync(), 'hello');
|
||||
expect(File(p.join(right, 'doc.txt')).existsSync(), isFalse);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import 'package:jj_mkvmaker/ui/explorer_search.dart';
|
|||
import 'package:jj_mkvmaker/ui/quick_look.dart';
|
||||
import 'package:jj_mkvmaker/ui/storage_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 관리자 지시 5 (10/17): 기기 없이 15 가 볼 수 있게 묶음6 새 화면을 PNG 로 (JJ_SHOT_DIR, 없으면 임시 폴더).
|
||||
|
|
@ -39,7 +38,8 @@ void main() {
|
|||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final tmp = testTempDir('jj_b6shot_');
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_b6shot_');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
// 시험 파일
|
||||
File(p.join(tmp.path, 'Show', 'ep01.mkv'))
|
||||
..createSync(recursive: true)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import 'package:jj_mkvmaker/ui/theme.dart';
|
|||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../test/support/fake_recycle_bin.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 기기 감독 (Windows) 확인용: 실제 앱 화면을 띄워 누르고 단계마다 PNG (JJ_SHOT_DIR) 로 남긴다.
|
||||
/// 65 · 94 · 98 (휴지통 · 휴지통 없는 위치 · 오류 문구), 37 (경로 입력), 124 · 130 (마스터 · 최상), 123 (AI 그림).
|
||||
|
|
@ -31,7 +30,9 @@ void main() {
|
|||
// 시험 폴더: 항목이 아주 많은 시스템 임시 폴더 대신 (JJ_SHOT_TMP, 없으면 시스템 임시 폴더)
|
||||
Directory tempDir(String prefix) {
|
||||
final base = Platform.environment['JJ_SHOT_TMP'];
|
||||
return testTempDir(prefix, base: base);
|
||||
if (base == null || base.isEmpty) return Directory.systemTemp.createTempSync(prefix);
|
||||
Directory(base).createSync(recursive: true);
|
||||
return Directory(base).createTempSync(prefix);
|
||||
}
|
||||
final key = GlobalKey();
|
||||
const sizes = [('narrow', Size(700, 900)), ('wide', Size(1280, 800))];
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/core/ftp.dart';
|
||||
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/ftp_settings.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 관리자 지시 (10/18): FTP · FTPS 화면을 PNG 로 (JJ_SHOT_DIR) - 서버 추가 창 (FTPS 처음 값 · 평문 경고) · 인증서 지문 확인 창 · 설정 목록.
|
||||
/// 크기 412 · 960, 언어 ko · en · ja. 설정은 메모리에만. 실행: flutter test integration_test\windows_ftp_shot_test.dart -d windows
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_ftp');
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('FTP 화면 $lang ${w}x$h', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings.ftpServers = [
|
||||
const FtpServer(id: 'f1', name: 'NAS FTPS', host: '192.168.0.15', user: 'jj'),
|
||||
const FtpServer(id: 'f2', name: '옛 공유기', host: '192.168.0.1', security: 'none'),
|
||||
];
|
||||
final navKey = GlobalKey<NavigatorState>();
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(
|
||||
theme: buildTheme(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
navigatorKey: navKey,
|
||||
home: Scaffold(body: SingleChildScrollView(child: FtpSettings(c: c)))),
|
||||
));
|
||||
BuildContext ctx() => navKey.currentContext!;
|
||||
Future<void> settle([int n = 8]) async {
|
||||
for (var i = 0; i < n; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> shot(String name) async {
|
||||
await settle(3);
|
||||
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'ftp_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
Future<void> closeAll() async {
|
||||
navKey.currentState!.popUntil((r) => r.isFirst);
|
||||
await settle(4);
|
||||
}
|
||||
|
||||
await settle();
|
||||
await shot('list');
|
||||
// 서버 추가 창 (처음 값 FTPS 명시)
|
||||
editFtpServer(ctx(), c);
|
||||
await settle();
|
||||
await shot('add');
|
||||
// 평문 FTP 를 고르면 빨간 경고
|
||||
await t.tap(find.textContaining(RegExp('FTP \\(|平文|明文|Plain')).last);
|
||||
await settle();
|
||||
await t.scrollUntilVisible(find.byType(TextField).at(2), 100, scrollable: find.byType(Scrollable).last);
|
||||
await settle();
|
||||
await shot('add_plain');
|
||||
await closeAll();
|
||||
// 자체 서명 인증서 확인 창
|
||||
askTrustFtpCert(
|
||||
ctx(),
|
||||
'NAS FTPS',
|
||||
const FtpCertException('x', FtpFailure.certUnknown,
|
||||
fingerprint: 'SHA256:E8:A8:51:BE:6E:78:58:B6:AC:53:03:6D:CB:CE:31:7A:C9:55:D2:55:BA:33:4D:DE:4B:CF:16:4B:6F:C4:90:C1',
|
||||
subject: '/CN=ds1815'));
|
||||
await settle();
|
||||
await shot('cert');
|
||||
await closeAll();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/live_sync.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
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/monitor_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 관리자 3a 조건 (10/20): 실시간 동기화의 지울지 묻는 창 - --delete-excluded 로 지워지는 거른 것을 따로 묶은 모양을
|
||||
/// 크기 412 · 960, 언어 ko · en · ja 로 찍는다. 시험 폴더 안에서만 (확인 창은 [나중에] - 지우지 않음).
|
||||
/// 실행: flutter test integration_test\windows_live_excl_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_live_excl');
|
||||
final rsync = p.join(Directory.current.path, 'third_party', 'rsync', 'windows', 'rsync.exe');
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('지울지 묻는 창 $lang ${w}x$h', (t) async {
|
||||
if (!File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음');
|
||||
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final tmp = testTempDir('jj_liveexcl_shot_');
|
||||
final src = p.join(tmp.path, 's'), dst = p.join(tmp.path, 'd');
|
||||
for (final (dir, rel) in [
|
||||
(src, 'a.txt'),
|
||||
(dst, 'only.txt'),
|
||||
(dst, '오래된 메모.txt'),
|
||||
(dst, 'skip.tmp'),
|
||||
(dst, 'cache/x.tmp'),
|
||||
(dst, 'build/obj/very_long_intermediate_file_name.tmp'),
|
||||
]) {
|
||||
File(p.join(dir, rel))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync(rel);
|
||||
}
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
..rsyncSource = 'custom'
|
||||
..rsyncPath = rsync
|
||||
..rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
|
||||
// 이미 --exclude 만으로 확인한 쌍에 --delete-excluded 를 더함 → 다시 묻는 창 (주황 이유 줄 · 거른 것 묶음)
|
||||
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-av --exclude=*.tmp');
|
||||
c.settings.liveSyncPairs = [pair];
|
||||
final live = LiveSync(c);
|
||||
addTearDown(live.dispose);
|
||||
await t.runAsync(() => live.syncNow(pair));
|
||||
expect(live.toDeleteExcluded[LiveSync.keyOf(pair)], hasLength(3));
|
||||
expect(live.reasked, contains(LiveSync.keyOf(pair)));
|
||||
final navKey = GlobalKey<NavigatorState>();
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(
|
||||
theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())),
|
||||
));
|
||||
unawaited(confirmLiveDeletes(navKey.currentContext!, live, pair));
|
||||
for (var i = 0; i < 8; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump(const Duration(milliseconds: 60));
|
||||
}
|
||||
expect(t.takeException(), isNull, reason: '넘침');
|
||||
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'live_excl_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
navKey.currentState!.pop();
|
||||
await t.pump();
|
||||
expect(File(p.join(dst, 'skip.tmp')).existsSync(), isTrue, reason: '[나중에] - 지우지 않음');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/live_sync.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
import 'package:jj_mkvmaker/core/sync_tools.dart' show toCygwinPath;
|
||||
import 'package:jj_mkvmaker/l10n/tr.dart';
|
||||
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/monitor_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙 (merge 파일 - 안에 "H a.txt") 이 있는 지우기 포함 쌍 - 모니터링 카드의 빨간 줄
|
||||
/// "자동으로 지우지 않습니다" · [지금 지우기 (확인)] 창 (FilterLimitNote · 체크) 을 412 · 960, ko · en · ja 로 찍는다.
|
||||
/// 시험 폴더 안에서만 (창은 [나중에] - 지우지 않음).
|
||||
/// 실행: flutter test integration_test\windows_lsync_p0_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_lsync_p0');
|
||||
final rsync = p.join(Directory.current.path, 'third_party', 'rsync', 'windows', 'rsync.exe');
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('미리 볼 수 없는 규칙의 쌍 $lang ${w}x$h', (t) async {
|
||||
if (!File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음');
|
||||
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final tmp = testTempDir('jj_lsyncp0_shot_');
|
||||
final src = p.join(tmp.path, 's'), dst = p.join(tmp.path, 'd');
|
||||
for (final (dir, rel) in [(src, 'a.txt'), (src, 'b.txt'), (dst, 'a.txt'), (dst, 'old.log'), (dst, 'extra.txt')]) {
|
||||
File(p.join(dir, rel))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('$dir/$rel');
|
||||
}
|
||||
File(p.join(tmp.path, 'rules.txt')).writeAsStringSync('H a.txt\n');
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
..rsyncSource = 'custom'
|
||||
..rsyncPath = rsync
|
||||
..rsyncOptions = '-a --filter="merge ${toCygwinPath(p.join(tmp.path, 'rules.txt'))}"'
|
||||
..liveSyncPairs = [LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true)];
|
||||
final live = LiveSync(c)..start();
|
||||
addTearDown(live.dispose);
|
||||
final pair = c.settings.liveSyncPairs.single;
|
||||
await t.runAsync(() => live.syncNow(pair));
|
||||
expect(live.noAutoDelete[LiveSync.keyOf(pair)], isNotEmpty);
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(MaterialApp(
|
||||
theme: buildTheme(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
builder: (_, child) => RepaintBoundary(key: shotKey, child: child),
|
||||
home: MonitorPage(c: c),
|
||||
));
|
||||
Future<void> settle([int n = 8]) async {
|
||||
for (var i = 0; i < n; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 80)));
|
||||
await t.pump(const Duration(milliseconds: 80));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> shot(String name) async {
|
||||
await settle(3);
|
||||
expect(t.takeException(), isNull, reason: '$name 넘침');
|
||||
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'lsync_p0_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
await t.tap(find.text(tr('lsync (실시간 동기화)')));
|
||||
await settle();
|
||||
expect(find.text(tr('이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다')), findsOneWidget);
|
||||
await shot('1_card');
|
||||
await t.tap(find.text(tr('지금 지우기 (확인)')));
|
||||
await settle(12);
|
||||
expect(find.textContaining(tr('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)')), findsOneWidget);
|
||||
await shot('2_now');
|
||||
await t.tap(find.text(tr('나중에')));
|
||||
await settle();
|
||||
expect(File(p.join(dst, 'old.log')).existsSync() && File(p.join(dst, 'a.txt')).existsSync(), isTrue, reason: '찍기만 - 지우지 않음');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/app/download_manager.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
import 'package:jj_mkvmaker/core/download_detect.dart';
|
||||
import 'package:jj_mkvmaker/l10n/tr.dart';
|
||||
import 'package:jj_mkvmaker/services/downloader.dart';
|
||||
import 'package:jj_mkvmaker/ui/downloads_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 이름을 바꾸지 않는 가짜 백엔드 (아무것도 받지 않음 - 줄의 상태는 시험이 정한다)
|
||||
class _Quiet implements DownloadBackend {
|
||||
@override
|
||||
final DownloadKind kind;
|
||||
_Quiet(this.kind);
|
||||
@override
|
||||
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => null;
|
||||
@override
|
||||
Future<void> start(DownloadTask t, void Function() changed) async {
|
||||
t
|
||||
..state = DownloadState.downloading
|
||||
..progress = null;
|
||||
changed();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause(DownloadTask t) async => t.state = DownloadState.paused;
|
||||
@override
|
||||
Future<void> cancel(DownloadTask t) async => t.state = DownloadState.cancelled;
|
||||
@override
|
||||
Future<void> shutdown() async {}
|
||||
}
|
||||
|
||||
/// 관리자 지시 (10/20, 28 확인): 마그넷 줄 - 메타데이터 전에도 dn 이름 · 멈춘 줄의 막대는 움직이지 않고 주황 "멈춤".
|
||||
/// 크기 412 · 960, 언어 ko · en · ja 로 찍는다 (가짜 백엔드 - 아무것도 받지 않음).
|
||||
/// 실행: flutter test integration_test\windows_magnet_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_magnet');
|
||||
const h = 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef0123456';
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, hh) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('마그넷 줄 $lang ${w}x$hh', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), hh.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final d = DownloadManager(
|
||||
backends: [_Quiet(DownloadKind.video), _Quiet(DownloadKind.torrent)],
|
||||
settings: () => AppSettings()..downloadRoot = r'D:\dl',
|
||||
readClipboard: () async => null,
|
||||
);
|
||||
// ① 메타데이터를 받는 중 (dn 이름 · 움직이는 "준비 중…") ② 멈춤 (이유 후보 · [다시 시도]) ③ 이름이 없는 마그넷
|
||||
d.add('${h}1&dn=${Uri.encodeQueryComponent('[시험] 아주 긴 이름의 토렌트 묶음 - Big Buck Bunny 1080p (2008) 한글 자막 포함')}&tr=udp%3A%2F%2Ft.example%3A80');
|
||||
d.add('${h}2&dn=Ubuntu+24.04.1+Desktop+amd64&tr=udp%3A%2F%2Ft.example%3A80');
|
||||
d.add('${h}3');
|
||||
final stalled = d.tasks.firstWhere((x) => x.title.startsWith('Ubuntu'));
|
||||
stalled.extra
|
||||
..['stalled'] = true
|
||||
..['stallSince'] = DateTime.now().millisecondsSinceEpoch - 75000
|
||||
..['stallReasons'] = ['dns', 'firewall'];
|
||||
d.useSystemDns = () async {};
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: DownloadsPage(d: d)),
|
||||
));
|
||||
for (var i = 0; i < 6; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump(const Duration(milliseconds: 60));
|
||||
}
|
||||
expect(t.takeException(), isNull, reason: '넘침');
|
||||
expect(find.textContaining('[시험] 아주 긴 이름'), findsOneWidget);
|
||||
expect(find.textContaining('Ubuntu 24.04.1 Desktop amd64'), findsOneWidget);
|
||||
expect(find.text(tr('멈춤 (시작 안 됨)')), findsOneWidget);
|
||||
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'magnet_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/core/sync_tools.dart';
|
||||
import 'package:jj_mkvmaker/ui/sync_preview_view.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 관리자 지시 (10/20, 28 지적): 알아볼 수 없는 옵션 안내 - 끝 문구가 문제 종류에 맞는지, 412 에서 넘치지 않는지.
|
||||
/// 확인 창의 빨간 글 (OptionProblemNote) 과 설정 칸 아래 글 (errorText, 3줄까지) 을 크기 412 · 960, 언어 ko · en · ja 로 찍는다.
|
||||
/// 실행: flutter test integration_test\windows_option_problem_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_optproblem');
|
||||
const cases = [
|
||||
'-a --delete --exclude="a.tmp --exclude=keep.txt', // 닫히지 않은 따옴표
|
||||
'-a delete', // 경로로 읽힘
|
||||
'-a --delete --exclude', // 값 없음
|
||||
'-a ---delete «x»', // 이상한 글자
|
||||
'-av delete --exclude', // 여러 종류
|
||||
];
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, hh) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('옵션 안내 $lang ${w}x$hh', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), hh.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(
|
||||
theme: buildTheme(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: Scaffold(
|
||||
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||||
for (final o in cases) ...[
|
||||
Text(o, style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
|
||||
OptionProblemNote(optionProblems(o)),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
// 설정 칸 (copy_sync_settings 의 rsync 옵션 칸과 같은 폭 · 글 줄 수)
|
||||
SizedBox(
|
||||
width: 380,
|
||||
child: TextField(
|
||||
controller: TextEditingController(text: cases.last),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: optionProblemText(optionProblems(cases.last)),
|
||||
errorMaxLines: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
));
|
||||
for (var i = 0; i < 4; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump(const Duration(milliseconds: 60));
|
||||
}
|
||||
expect(t.takeException(), isNull, reason: '넘침');
|
||||
expect(find.byType(OptionProblemNote), findsNWidgets(cases.length));
|
||||
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'optproblem_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/ui/file_tools.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 관리자 지시 (10/18): X-plore P3 화면을 PNG 로 (JJ_SHOT_DIR, 없으면 임시 폴더) - 해시 창 · 폴더 속성 · 16진 보기.
|
||||
/// 크기 412 (폰 세로) · 960, 언어 ko · en · ja. 시험 파일은 임시 폴더에만 (끝나면 지움). 실행: flutter test integration_test\windows_p3_shot_test.dart -d windows
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_p3');
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('P3 화면 $lang ${w}x$h', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final tmp = testTempDir('jj_p3shot_');
|
||||
final file = File(p.join(tmp.path, '영상 폴더', 'ep01 긴 이름의 동영상 파일.mkv'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsBytesSync(List.generate(5000, (i) => i % 256));
|
||||
File(p.join(tmp.path, '영상 폴더', '속', 'a.txt'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('hello');
|
||||
final navKey = GlobalKey<NavigatorState>();
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(
|
||||
theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())),
|
||||
));
|
||||
BuildContext ctx() => navKey.currentContext!;
|
||||
Future<void> settle([int n = 8]) async {
|
||||
for (var i = 0; i < n; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> shot(String name) async {
|
||||
await settle(3);
|
||||
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'p3_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
Future<void> closeAll() async {
|
||||
navKey.currentState!.popUntil((r) => r.isFirst);
|
||||
await settle(4);
|
||||
}
|
||||
|
||||
// 해시 창 (다 계산된 뒤 · 붙여 넣은 값과 비교)
|
||||
showFileHash(ctx(), file.path);
|
||||
await settle(12);
|
||||
await shot('hash');
|
||||
await t.enterText(find.byType(TextField).first, 'abc');
|
||||
await settle();
|
||||
await shot('hash_compare');
|
||||
await closeAll();
|
||||
// 폴더 속성
|
||||
showFolderProperties(ctx(), p.join(tmp.path, '영상 폴더'));
|
||||
await settle(12);
|
||||
await shot('props');
|
||||
await closeAll();
|
||||
// 16진 보기
|
||||
navKey.currentState!.push(MaterialPageRoute<void>(builder: (_) => HexViewerPage(path: file.path)));
|
||||
await settle(12);
|
||||
await shot('hex');
|
||||
await closeAll();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/l10n/tr.dart';
|
||||
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/settings_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 관리자 판단 (10/20): 환경 설정 > 파일 탐색기의 "원격 서버 응답 기다리기" (30 · 60 · 120 · 300초) 를
|
||||
/// 412 · 960, ko · en · ja 로 찍는다 (설정은 바꾸지 않음 - 시험 안의 설정만).
|
||||
/// 실행: flutter test integration_test\windows_remote_idle_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_remote_idle');
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('원격 서버 응답 기다리기 $lang ${w}x$h', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
final key = GlobalKey();
|
||||
await t.pumpWidget(MaterialApp(
|
||||
theme: buildTheme(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: RepaintBoundary(key: key, child: SettingsPage(c: c)),
|
||||
));
|
||||
await t.pump(const Duration(milliseconds: 300));
|
||||
final tile = find.text(tr('원격 서버 응답 기다리기'));
|
||||
await t.scrollUntilVisible(tile, 300,
|
||||
scrollable: find.byWidgetPredicate((x) => x is Scrollable && x.axisDirection == AxisDirection.down).first, maxScrolls: 400);
|
||||
await t.pump(const Duration(milliseconds: 300));
|
||||
await t.ensureVisible(tile);
|
||||
await t.pump(const Duration(milliseconds: 300));
|
||||
expect(t.takeException(), isNull, reason: '넘침');
|
||||
expect(find.text(trf('{0}초 (기본)', [60])), findsOneWidget);
|
||||
final b = key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'remote_idle_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/master_lock.dart';
|
||||
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/services/secret_store.dart';
|
||||
import 'package:jj_mkvmaker/ui/master_prompt.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 145 (관리자 지시 10/19): 124 초기화 창 (최상 비밀번호 - 초기화 · 정말 초기화할까요? · 마스터를 잊었을 때) 을
|
||||
/// 크기 412 · 960, 언어 ko · en · ja 로 찍는다. 아무것도 지우지 않는다 (가짜 저장소, [초기화] 는 누르지 않음).
|
||||
/// 실행: flutter test integration_test\windows_reset_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_reset');
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('초기화 창 $lang ${w}x$h', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
final lock = MasterLock(MemorySecretStore());
|
||||
await t.runAsync(() async {
|
||||
await lock.load();
|
||||
await lock.setMaster('시험-마스터');
|
||||
});
|
||||
final navKey = GlobalKey<NavigatorState>();
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(
|
||||
theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())),
|
||||
));
|
||||
Future<void> settle([int n = 8]) async {
|
||||
for (var i = 0; i < n; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> shot(String name) async {
|
||||
await settle(3);
|
||||
expect(t.takeException(), isNull, reason: '$name 넘침');
|
||||
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'reset_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
Future<void> closeAll() async {
|
||||
navKey.currentState!.popUntil((r) => r.isFirst);
|
||||
await settle();
|
||||
}
|
||||
|
||||
// ① 최상 비밀번호 - 초기화 (무엇을 초기화할지) - 셋 다 체크해 긴 설명까지 보이게
|
||||
unawaited(showSuperReset(navKey.currentContext!, c, lock));
|
||||
await settle();
|
||||
await shot('1_super');
|
||||
for (final cb in find.byType(CheckboxListTile).evaluate().toList()) {
|
||||
final tile = cb.widget as CheckboxListTile;
|
||||
if (tile.value != true && tile.onChanged != null) tile.onChanged!(true);
|
||||
}
|
||||
await settle();
|
||||
await shot('2_super_all');
|
||||
// ② 정말 초기화할까요? (한 번 더 확인) - 여기서는 [취소]
|
||||
await t.tap(find.byType(FilledButton).last);
|
||||
await settle();
|
||||
await shot('3_sure');
|
||||
await closeAll();
|
||||
|
||||
// ③ 마스터를 잊었을 때 (저장된 비밀번호를 지우고 마스터 초기화) - 확인 창만
|
||||
unawaited(forgotMaster(navKey.currentContext!, c, lock));
|
||||
await settle();
|
||||
await shot('4_forgot');
|
||||
await closeAll();
|
||||
expect(lock.hasMaster, isTrue, reason: '찍기만 하고 지우지 않음');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/l10n/tr.dart';
|
||||
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/explorer_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 28 확인 (10/20): 좁은 Rsync 화면의 [오른쪽] 탭을 처음 볼 때 그 창의 지금 폴더가 보이는지 · 확인 창의 체크 제목
|
||||
/// "(--remove-source-files)" 가 "(--" 에서 갈리지 않는지를 412 · 960, ko · en · ja 로 찍는다 (시험 폴더 안에서만, [취소]).
|
||||
/// 실행: flutter test integration_test\windows_rsync_narrow_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_rsync_narrow');
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('좁은 Rsync 화면 $lang ${w}x$h', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final tmp = testTempDir('jj_rsnarrow_shot_');
|
||||
// 맨 위에 폴더가 많고 (C: 처럼) 두 창의 지금 폴더는 깊은 곳
|
||||
for (var i = 0; i < 60; i++) {
|
||||
Directory(p.join(tmp.path, 'a${i.toString().padLeft(2, '0')}')).createSync();
|
||||
}
|
||||
final l = p.join(tmp.path, 'zz', 'deep', 'L'), r = p.join(tmp.path, 'zz', 'deep', 'R');
|
||||
File(p.join(l, 'sub', 'a.txt'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('a');
|
||||
Directory(p.join(r, 'rsub')).createSync(recursive: true);
|
||||
for (var i = 0; i < 20; i++) {
|
||||
Directory(p.join(tmp.path, 'zz${i.toString().padLeft(2, '0')}')).createSync();
|
||||
}
|
||||
ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')];
|
||||
addTearDown(() => ExplorerPage.debugVolumes = null);
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
..explorerOrientation = 'side'
|
||||
..rsyncPaths = [l, r];
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(MaterialApp(
|
||||
theme: buildTheme(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
builder: (_, child) => RepaintBoundary(key: shotKey, child: child),
|
||||
home: ExplorerPage(c: c, rsync: true),
|
||||
));
|
||||
Future<void> settle([int n = 10]) async {
|
||||
for (var i = 0; i < n; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump(const Duration(milliseconds: 60));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> shot(String name) async {
|
||||
await settle(3);
|
||||
expect(t.takeException(), isNull, reason: '$name 넘침');
|
||||
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'rsync_narrow_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
await settle(20);
|
||||
final narrow = find.text('${tr('오른쪽')} · R').evaluate().isNotEmpty;
|
||||
await t.tap(find.text('sub').hitTestable());
|
||||
await settle();
|
||||
if (narrow) {
|
||||
await t.tap(find.text('${tr('오른쪽')} · R'));
|
||||
await settle(15);
|
||||
expect(find.text('rsub').hitTestable(), findsOneWidget, reason: '[오른쪽] 탭을 처음 보면 지금 폴더');
|
||||
await shot('1_right_tab');
|
||||
}
|
||||
await t.tap(find.text('rsub').hitTestable());
|
||||
await settle();
|
||||
await t.tap(find.text(tr('좌 → 우')).first);
|
||||
await settle(25);
|
||||
await shot('2_confirm');
|
||||
await t.tap(find.text(tr('취소')).last);
|
||||
await settle();
|
||||
expect(File(p.join(l, 'sub', 'a.txt')).existsSync(), isTrue, reason: '찍기만 - 실행하지 않음');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/core/lan_scan.dart';
|
||||
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/smb_settings.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 관리자 지시 (10/18): X-plore j 화면을 PNG 로 (JJ_SHOT_DIR) - SMB 서버 추가 창의 [같은 망에서 찾기] · 찾는 중 · 찾은 목록 · 못 찾음.
|
||||
/// 실제 망은 훑지 않는다 (가짜 찾기). 크기 412 · 960, 언어 ko · en · ja. 실행: flutter test integration_test\windows_smbfind_shot_test.dart -d windows
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_smbfind');
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('SMB 찾기 화면 $lang ${w}x$h', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
addTearDown(() => debugFindSmbServers = null);
|
||||
var hold = true;
|
||||
var result = const [LanHost('192.168.0.15', 'ds1815.local'), LanHost('192.168.0.20', null)];
|
||||
debugFindSmbServers = ({cancelled, onProgress}) async {
|
||||
onProgress?.call(97, 254);
|
||||
while (hold) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
final navKey = GlobalKey<NavigatorState>();
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(
|
||||
theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())),
|
||||
));
|
||||
Future<void> settle([int n = 8]) async {
|
||||
for (var i = 0; i < n; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> shot(String name) async {
|
||||
await settle(3);
|
||||
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'smbfind_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
editSmbServer(navKey.currentContext!, c);
|
||||
await settle();
|
||||
await shot('add');
|
||||
await t.tap(find.byIcon(Icons.radar));
|
||||
await settle();
|
||||
await shot('finding');
|
||||
hold = false;
|
||||
await settle();
|
||||
await shot('found');
|
||||
await t.tap(find.text('ds1815.local'));
|
||||
await settle();
|
||||
await shot('filled');
|
||||
result = const [];
|
||||
hold = false;
|
||||
await t.tap(find.byIcon(Icons.radar));
|
||||
await settle();
|
||||
await shot('none');
|
||||
navKey.currentState!.popUntil((r) => r.isFirst);
|
||||
await settle(4);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/ui/text_viewer.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 관리자 지시 (10/19): 글 뷰어가 줄바꿈 없는 긴 줄 (영문 · 한글 섞임) 을 나눠 그릴 때 실제 Windows 글꼴에서 줄 끝이 비지 않는지 PNG 로.
|
||||
/// 크기 412 · 960. 실행: flutter test integration_test\windows_textview_shot_test.dart -d windows
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_textview');
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
for (final (name, line) in [
|
||||
('ascii', List.generate(3000, (i) => String.fromCharCode(0x61 + i % 26)).join()),
|
||||
('mixed', List.generate(3000, (i) => i.isEven ? '한' : 'x').join()),
|
||||
('korean', '가나다라마바사아자차카타파하' * 200),
|
||||
// 28 지적 (10/19): 이모지 (두 단위 글자) 가 든 긴 줄 - 재는 조각이 글자 가운데서 끊기면 UTF-16 오류
|
||||
('emoji', List.generate(3000, (i) => 'abc😀').join()),
|
||||
]) {
|
||||
testWidgets('글 뷰어 긴 줄 $name ${w}x$h', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
final dir = testTempDir('jj_textview_');
|
||||
final f = File(p.join(dir.path, '$name.txt'))..writeAsStringSync('$line\nend');
|
||||
final key = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: key,
|
||||
child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: TextViewerPage(path: f.path)),
|
||||
));
|
||||
for (var i = 0; i < 40 && find.text('end').evaluate().isEmpty && find.textContaining(line.substring(0, 6)).evaluate().isEmpty; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump();
|
||||
}
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 200)));
|
||||
await t.pump();
|
||||
final b = key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'textview_${name}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
expect(t.takeException(), isNull, reason: '그리는 중 오류 없음 ($name $w)');
|
||||
await t.pumpWidget(const SizedBox());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
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/ftp_settings.dart';
|
||||
import 'package:jj_mkvmaker/ui/sftp_settings.dart';
|
||||
import 'package:jj_mkvmaker/ui/smb_settings.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:jj_mkvmaker/ui/webdav_settings.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 관리자 지시 (10/18): 412 캡처의 잘림 고침 확인 - FTP · SFTP · SMB · WebDAV 서버 추가 창 (칸 이름은 짧게, 설명은 helper).
|
||||
/// 크기 412 · 960, 언어 ko · en · ja. 실행: flutter test integration_test\windows_trunc_shot_test.dart -d windows
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_trunc');
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('서버 추가 창 $lang ${w}x$h', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
final navKey = GlobalKey<NavigatorState>();
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(
|
||||
theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())),
|
||||
));
|
||||
Future<void> settle([int n = 8]) async {
|
||||
for (var i = 0; i < n; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> shot(String name) async {
|
||||
await settle(3);
|
||||
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'trunc_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
for (final (name, open) in <(String, void Function(BuildContext))>[
|
||||
('ftp', (ctx) => editFtpServer(ctx, c)),
|
||||
('sftp', (ctx) => editSftpServer(ctx, c)),
|
||||
('smb', (ctx) => editSmbServer(ctx, c)),
|
||||
('webdav', (ctx) => editDavServer(ctx, c)),
|
||||
]) {
|
||||
open(navKey.currentContext!);
|
||||
await settle();
|
||||
await shot(name);
|
||||
// 창 아래쪽 (시작 폴더 칸) 도
|
||||
final scroll = find.descendant(of: find.byType(AlertDialog), matching: find.byType(Scrollable));
|
||||
if (scroll.evaluate().isNotEmpty) {
|
||||
await t.drag(scroll.first, const Offset(0, -600));
|
||||
await settle(4);
|
||||
await shot('${name}_bottom');
|
||||
}
|
||||
navKey.currentState!.popUntil((r) => r.isFirst);
|
||||
await settle(4);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/core/wifi_share.dart';
|
||||
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/theme.dart';
|
||||
import 'package:jj_mkvmaker/ui/wifi_share_page.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 관리자 지시 (10/18): 89 Wi-Fi 공유 화면을 PNG 로 (JJ_SHOT_DIR) - 켜기 전 · 켠 뒤 (코드 · 주소 · QR · 남은 시간 · 접속 기록).
|
||||
/// 서버는 127.0.0.1 에만 연다 (시험 PC 의 방화벽 창 · 바깥 노출 없이), 주소 줄은 시험용 사설 주소로 바꿔 보인다.
|
||||
/// 크기 412 · 960, 언어 ko · en · ja. 실행: flutter test integration_test\windows_wifi_shot_test.dart -d windows
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_wifi');
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('Wi-Fi 공유 화면 $lang ${w}x$h', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
WifiShare.debugLoopbackOnly = true;
|
||||
// 화면에는 이 PC 의 실제 주소 대신 시험용 사설 주소를 (캡처에 사무실 망 주소가 남지 않게)
|
||||
WifiShare.debugAddresses = const ['192.168.0.23'];
|
||||
addTearDown(() async {
|
||||
await WifiShare.instance?.stop();
|
||||
WifiShare.debugLoopbackOnly = false;
|
||||
WifiShare.debugAddresses = null;
|
||||
});
|
||||
final tmp = testTempDir('jj_wifishot_');
|
||||
final dir = Directory(p.join(tmp.path, '여행 사진'))..createSync();
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: WifiSharePage(c: c, folder: dir.path)),
|
||||
));
|
||||
Future<void> settle([int n = 8]) async {
|
||||
for (var i = 0; i < n; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> shot(String name) async {
|
||||
await settle(3);
|
||||
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'wifi_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
await settle();
|
||||
await shot('off');
|
||||
await t.tap(find.byIcon(Icons.wifi_tethering).first);
|
||||
for (var i = 0; i < 30 && WifiShare.instance == null; i++) {
|
||||
await settle(1);
|
||||
}
|
||||
await settle();
|
||||
await shot('on');
|
||||
await t.scrollUntilVisible(find.textContaining(RegExp(r'\(0\)')), 200, scrollable: find.byType(Scrollable).first);
|
||||
await shot('on_log');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:archive/archive.dart' show Archive, ArchiveFile, ZipEncoder;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
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/explorer_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// X-plore 비교 P1 (관리자 지시 10/18): X-plore 스타일 탐색기를 PNG 로 (JJ_SHOT_DIR, 없으면 임시 폴더).
|
||||
/// 트리 맨 위 저장 장치 · 즐겨찾기 (c) · 표시 동그라미 늘 보이기 (⚖) · 한 창의 "갈 곳" 줄 (a) · 압축을 폴더처럼 (f).
|
||||
/// 크기 412 (폰 세로, 한 창) · 960 (두 창), 언어 ko · en. 시험 파일은 임시 폴더에만, 설정은 메모리에만.
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_xplore');
|
||||
|
||||
for (final lang in ['ko', 'en']) {
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('X-plore 화면 $lang ${w}x$h', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final tmp = testTempDir('jj_xpshot_');
|
||||
appTempInto(tmp); // 앱 임시 폴더 (jj_mkvmaker) 도 이 아래로 - 끝에 함께 지움 (관리자 지시 10/20)
|
||||
final a = Directory(p.join(tmp.path, 'Videos'))..createSync();
|
||||
final b = Directory(p.join(tmp.path, 'Backup'))..createSync();
|
||||
for (final n in ['Show S01', 'Movies']) {
|
||||
Directory(p.join(a.path, n)).createSync();
|
||||
}
|
||||
File(p.join(a.path, 'Show S01', 'ep01.mkv')).writeAsBytesSync(List.filled(5000, 0));
|
||||
File(p.join(a.path, 'notes.txt')).writeAsStringSync('memo');
|
||||
File(p.join(a.path, 'Photos.zip')).writeAsBytesSync(ZipEncoder().encode(Archive()
|
||||
..addFile(ArchiveFile.string('2026/beach.jpg', 'x'))
|
||||
..addFile(ArchiveFile.string('readme.txt', 'readme'))));
|
||||
ExplorerPage.debugVolumes = () => [(a.path, 'C:'), (b.path, 'D:')];
|
||||
addTearDown(() => ExplorerPage.debugVolumes = null);
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
..explorerStyle = 'xplore'
|
||||
..explorerLayout = w < 720 ? 'single' : 'dual'
|
||||
..explorerOnePaneCopy = 'other'
|
||||
..explorerPaths = [a.path, b.path]
|
||||
..folderBookmarks = [FolderBookmark(name: 'Show S01', path: p.join(a.path, 'Show S01'))];
|
||||
final shotKey = GlobalKey();
|
||||
await t.runAsync(() => t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: ExplorerPage(c: c)),
|
||||
)));
|
||||
Future<void> settle([int n = 12]) async {
|
||||
for (var i = 0; i < n; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> shot(String name) async {
|
||||
await settle(3);
|
||||
final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => r.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'xplore_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
await settle(20);
|
||||
// 관리자 지시 (10/20): 목록은 따로 읽혀 늦게 올 수 있다 (회귀에서 en 412 가 1.2초 안에 못 받아 실패) - 정해진 시간이 아니라 그 줄이 올 때까지 (최대 약 10초)
|
||||
final load = Stopwatch()..start();
|
||||
for (var i = 0; i < 160 && find.text('Show S01').evaluate().isEmpty; i++) {
|
||||
await settle(1);
|
||||
}
|
||||
// ignore: avoid_print
|
||||
print('목록 받음: $lang $w - 1.2초 뒤 ${load.elapsedMilliseconds}ms 더');
|
||||
// Show S01 펼치기 · 즐겨찾기 펼치기
|
||||
await t.tap(find.text('Show S01').first);
|
||||
await settle();
|
||||
await t.tap(find.byKey(const ValueKey<String>('k2:\u0000favorites')).first);
|
||||
await settle();
|
||||
await shot('tree');
|
||||
// f: 압축을 › 로 펼치기 (안의 폴더도)
|
||||
Finder chevron(String name) => find.descendant(
|
||||
of: find.ancestor(of: find.text(name), matching: find.byType(InkWell)).first, matching: find.byIcon(Icons.chevron_right));
|
||||
await t.tap(chevron('Photos.zip').first);
|
||||
await settle();
|
||||
await t.tap(chevron('2026').first);
|
||||
await settle();
|
||||
await shot('archive');
|
||||
await t.pumpWidget(const SizedBox());
|
||||
await settle(4);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/core/batch_rename.dart';
|
||||
import 'package:jj_mkvmaker/core/file_ops.dart';
|
||||
import 'package:jj_mkvmaker/core/text_codec.dart';
|
||||
import 'package:jj_mkvmaker/ui/batch_rename_dialog.dart';
|
||||
import 'package:jj_mkvmaker/ui/explorer_grid.dart';
|
||||
import 'package:jj_mkvmaker/ui/storage_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/text_viewer.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// X-plore 비교 g · h · i · P3 (관리자 지시 10/18): 격자 보기 · 글 뷰어 · 일괄 이름 (날짜) · 저장 공간 그림을 PNG 로
|
||||
/// (JJ_SHOT_DIR, 없으면 임시 폴더). 크기 412 · 960, 언어 ko · en. 시험 파일은 임시 폴더에만.
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_xptools');
|
||||
|
||||
for (final lang in ['ko', 'en']) {
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('X-plore 도구 $lang ${w}x$h', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final tmp = testTempDir('jj_xptshot_');
|
||||
// 그림 (색이 다른 작은 그림) · 동영상 이름 · 글 (CP949)
|
||||
final pics = <String>[];
|
||||
for (var i = 0; i < 7; i++) {
|
||||
final im = img.Image(width: 64, height: 48);
|
||||
img.fill(im, color: img.ColorRgb8(40 + i * 30, 120, 200 - i * 20));
|
||||
if (i == 0) im.exif.exifIfd['DateTimeOriginal'] = img.IfdValueAscii('2024:05:01 12:34:56');
|
||||
final f = File(p.join(tmp.path, 'IMG_${1000 + i}.jpg'))..writeAsBytesSync(img.encodeJpg(im));
|
||||
pics.add(f.path);
|
||||
}
|
||||
File(p.join(tmp.path, 'clip.mkv')).writeAsBytesSync(List.filled(10, 0));
|
||||
final txt = File(p.join(tmp.path, 'memo.txt'))
|
||||
..writeAsBytesSync(encodeText(List.generate(30, (i) => '${i + 1}번째 줄 - 한글 메모 line $i').join('\n'), 'CP949').bytes);
|
||||
for (final (n, size) in [('Videos', 900000), ('Photos', 400000), ('Docs', 120000), ('Music', 60000)]) {
|
||||
final d = Directory(p.join(tmp.path, 'disk', n))..createSync(recursive: true);
|
||||
File(p.join(d.path, 'big.bin')).writeAsBytesSync(List.filled(size, 0));
|
||||
}
|
||||
File(p.join(tmp.path, 'disk', 'loose.bin')).writeAsBytesSync(List.filled(80000, 0));
|
||||
|
||||
final shotKey = GlobalKey();
|
||||
final nav = GlobalKey<NavigatorState>();
|
||||
await t.runAsync(() => t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(navigatorKey: nav, theme: buildTheme(), debugShowCheckedModeBanner: false, home: const Scaffold()),
|
||||
)));
|
||||
Future<void> settle([int n = 12]) async {
|
||||
for (var i = 0; i < n; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> shot(String name) async {
|
||||
await settle(4);
|
||||
final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final image = await t.runAsync(() => r.toImage());
|
||||
final bytes = await t.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'xptools_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
// g 격자 보기
|
||||
final entries = [
|
||||
for (final x in [...pics, p.join(tmp.path, 'clip.mkv'), txt.path])
|
||||
FileEntry(x, isDir: false, size: File(x).lengthSync(), modified: DateTime(2026, 10, 18)),
|
||||
];
|
||||
nav.currentState!.push(MaterialPageRoute<void>(
|
||||
builder: (_) => ExplorerGridPage(
|
||||
title: 'Photos',
|
||||
entries: entries,
|
||||
imageExts: const ['jpg'],
|
||||
videoThumb: (_) async => null,
|
||||
onOpen: (_) async {},
|
||||
),
|
||||
));
|
||||
await settle(20);
|
||||
await shot('grid');
|
||||
nav.currentState!.pop();
|
||||
await settle();
|
||||
|
||||
// h 글 뷰어 (CP949 를 스스로 알아봄)
|
||||
nav.currentState!.push(MaterialPageRoute<void>(builder: (_) => TextViewerPage(path: txt.path)));
|
||||
await settle(20);
|
||||
await shot('text');
|
||||
nav.currentState!.pop();
|
||||
await settle();
|
||||
|
||||
// i 일괄 이름 바꾸기 - [D] (사진은 찍은 때)
|
||||
showBatchRename(nav.currentContext!, pics.take(4).toList(),
|
||||
isDir: (_) => false, initial: const RenameRule(template: '[D]_[T]_[C]'));
|
||||
await settle(30);
|
||||
await shot('rename_date');
|
||||
await t.tap(find.text(lang == 'ko' ? '취소' : 'Cancel').last);
|
||||
await settle();
|
||||
|
||||
// P3 저장 공간 그림
|
||||
nav.currentState!.push(MaterialPageRoute<void>(builder: (_) => StorageAnalysisPage(root: p.join(tmp.path, 'disk'))));
|
||||
await settle(30);
|
||||
await t.tap(find.text(lang == 'ko' ? '칸 그림' : 'Treemap'));
|
||||
await settle();
|
||||
await shot('storage_chart');
|
||||
nav.currentState!.pop();
|
||||
await settle();
|
||||
await t.pumpWidget(const SizedBox());
|
||||
await settle(4);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,6 @@ import '../core/mkv_command_builder.dart';
|
|||
import '../core/vfs.dart';
|
||||
import '../core/sftp.dart' show SftpRegistry;
|
||||
import '../core/smb.dart' show SmbRegistry;
|
||||
import '../core/ftp.dart' show FtpRegistry;
|
||||
import '../core/webdav.dart';
|
||||
import '../core/models.dart';
|
||||
import '../core/output_paths.dart';
|
||||
|
|
@ -31,9 +30,7 @@ import '../services/model_store.dart';
|
|||
import '../services/platform_services.dart';
|
||||
import '../services/system_usage.dart';
|
||||
import 'settings.dart';
|
||||
import 'cleanup.dart' show removeUpdateLeftovers;
|
||||
import '../l10n/tr.dart';
|
||||
import '../core/remote_idle.dart';
|
||||
|
||||
extension<T> on Set<T> {
|
||||
Set<T> ifEmpty(Set<T> other) => isEmpty ? other : this;
|
||||
|
|
@ -159,11 +156,7 @@ class AppController extends ChangeNotifier {
|
|||
// 88: SFTP 서버 (sftp:// 경로)
|
||||
SftpRegistry.configure(settings.sftpServers);
|
||||
// 88: Android SMB 서버 (smb:// 경로)
|
||||
SmbRegistry.requireSecure = settings.smbRequireSecure;
|
||||
// 관리자 판단 (10/20): 원격 서버 받기 · 올리기의 조각 사이 기다리기
|
||||
RemoteIdle.limit = Duration(seconds: settings.remoteIdleSeconds);
|
||||
SmbRegistry.configure(settings.smbServers);
|
||||
FtpRegistry.configure(settings.ftpServers);
|
||||
outputRootOverride =
|
||||
(settings.mkvOutputRoot?.isNotEmpty ?? false) ? settings.mkvOutputRoot : null;
|
||||
}
|
||||
|
|
@ -209,10 +202,6 @@ class AppController extends ChangeNotifier {
|
|||
);
|
||||
_applySettings();
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
final gone = removeUpdateLeftovers(Directory.systemTemp.path);
|
||||
if (gone.isNotEmpty) _log(trf('지난 업데이트가 남긴 임시 파일을 지움: {0}', [gone.join(', ')]));
|
||||
}
|
||||
ffmpegVersion = await _tool.version();
|
||||
if (ffmpegVersion == null) {
|
||||
_log(tr('⚠ FFmpeg 를 찾을 수 없습니다. MKV 만들기를 사용할 수 없습니다.'));
|
||||
|
|
|
|||
|
|
@ -7,29 +7,6 @@ import '../l10n/tr.dart';
|
|||
import 'app_controller.dart';
|
||||
import 'download_manager.dart';
|
||||
|
||||
/// 관리자 지시 (10/20): Windows 업데이트가 %TEMP% 에 남긴 것 - 풀어 둔 새 판 (jj_mkvmaker_update_<판>, 수백 MB) ·
|
||||
/// 교체 스크립트 (jj_mkvmaker_update.ps1) · 기록 (jj_mkvmaker_update.log). 예전에는 [정리] 를 누르기 전까지 남았다.
|
||||
/// 앱이 켜질 때 부른다. 교체 스크립트가 아직 끝나는 중일 수 있어 [minAge] 보다 오래된 것만 지운다. 지운 이름을 돌려준다
|
||||
List<String> removeUpdateLeftovers(String sysTmp, {Duration minAge = const Duration(minutes: 2), DateTime? now}) {
|
||||
final mine = RegExp(r'^jj_mkvmaker_update(_.+|\.ps1|\.log)$');
|
||||
final before = (now ?? DateTime.now()).subtract(minAge);
|
||||
final removed = <String>[];
|
||||
try {
|
||||
for (final e in Directory(sysTmp).listSync(followLinks: false)) {
|
||||
final name = p.basename(e.path);
|
||||
if (!mine.hasMatch(name)) continue;
|
||||
try {
|
||||
if (!e.statSync().modified.isBefore(before)) continue;
|
||||
e.deleteSync(recursive: true);
|
||||
removed.add(name);
|
||||
} catch (_) {
|
||||
// 잡혀 있으면 (기록을 메모장으로 열어 둔 것 등) 다음에
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// 정리할 파일 · 폴더 하나
|
||||
class CleanupItem {
|
||||
final String path;
|
||||
|
|
|
|||
|
|
@ -326,21 +326,6 @@ class DownloadManager extends ChangeNotifier {
|
|||
_changed();
|
||||
}
|
||||
|
||||
/// 관리자 지시 (10/20): 시작되지 않는 마그넷 · 토렌트 (받은 것 없음) 를 처음부터 다시 - 지금 것을 지우고 새로 넣는다
|
||||
Future<void> retryStalled(DownloadTask t) async {
|
||||
await backends[t.kind]!.cancel(t);
|
||||
t.extra
|
||||
..remove('gid')
|
||||
..remove('stallSince')
|
||||
..remove('stalled')
|
||||
..remove('stallReasons');
|
||||
t.files.clear();
|
||||
t
|
||||
..state = DownloadState.queued
|
||||
..error = null;
|
||||
_changed();
|
||||
}
|
||||
|
||||
/// 중지하고 받던 파일 삭제 (목록에는 '취소됨' 으로 남김)
|
||||
Future<void> cancel(Iterable<DownloadTask> list) async {
|
||||
for (final t in list.toList()) {
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ import '../l10n/tr.dart';
|
|||
import '../platform/windows/rsync_installer.dart';
|
||||
import 'app_controller.dart';
|
||||
import 'settings.dart';
|
||||
import '../core/rsync_filter.dart';
|
||||
import '../core/rsync_launch.dart';
|
||||
|
||||
/// 앱에 들어 있는 rsync (없으면 null). 시작할 때 [findBundledRsync] 로 정한다.
|
||||
/// - Windows: 실행 파일 옆 rsync/rsync.exe (tool/fetch_rsync.ps1 로 준비해 빌드에 넣음)
|
||||
|
|
@ -151,119 +149,8 @@ class LiveSync extends ChangeNotifier {
|
|||
}
|
||||
|
||||
/// 원본과 대상의 다른 점 (크기 · 바뀐 시각). [x.delete] 면 원본에 없는 대상 항목도 "− 이름" 으로.
|
||||
/// 관리자 지시 (10/19): rsync 쌍이 원격이라 앱이 맞출 때 (그리고 그 대기 목록) 도 환경 설정 rsync 옵션의 거르기를 따른다.
|
||||
/// 로컬 rsync 쌍은 rsync 가 직접 따르므로 대기 목록 (diff) 에도 같이
|
||||
RsyncFilter _filterOf(LiveSyncPair x) => x.method == 'rsync' ? RsyncFilter.parse(c.settings.rsyncOptions) : RsyncFilter.none;
|
||||
|
||||
/// 지울 것에 영향을 주는 옵션 (rsync 쌍만 - 거르기 · --delete-excluded). 확인할 때 쌍에 기억한다 ([LiveSyncPair.confirmedOptions])
|
||||
String _optionsOf(LiveSyncPair x) => x.method == 'rsync' ? c.settings.rsyncOptions : '';
|
||||
|
||||
/// 관리자 3a 판단 (10/20): 확인한 쌍이라도 옵션이 바뀌어 지울 것이 늘었으면 다시 묻는다 (로컬 · 원격 쌍 모두).
|
||||
/// 늘지 않았으면 (줄거나 같음) 묻지 않고 지금 옵션을 기억한다. 예전에 확인한 쌍 (기억한 옵션 없음) 은 예전 동작
|
||||
/// (--delete-excluded 를 따르지 않음) 과 견준다 - 업데이트 뒤 --delete-excluded 로 새로 지워질 것이 있으면 묻는다
|
||||
Future<bool> _deletesGrew(LiveSyncPair x) async {
|
||||
final old = x.confirmedOptions;
|
||||
final now = _filterOf(x);
|
||||
final before = old == null
|
||||
? RsyncFilter(now.rules, unsupported: now.unsupported)
|
||||
: x.method == 'rsync'
|
||||
? RsyncFilter.parse(old)
|
||||
: RsyncFilter.none;
|
||||
List<String> gone(List<String> d) => [for (final s in d) if (s.startsWith('− ')) s.substring(2)];
|
||||
final a = gone(await diff(x, limit: 100000, filter: now));
|
||||
if (a.isEmpty) return false;
|
||||
final b = gone(await diff(x, limit: 100000, filter: before));
|
||||
return a.any((d) => !b.any((e) => d == e || d.startsWith('$e/')));
|
||||
}
|
||||
|
||||
/// 확인한 뒤 옵션이 바뀌어 다시 묻는 쌍 (확인 창에 그 이유를 보인다)
|
||||
final reasked = <String>{};
|
||||
|
||||
/// P0 (관리자 3a 10/20, 28 재현): 지우기 포함 rsync 쌍의 옵션에 미리 볼 수 없는 거르기 규칙 (-,s · H · P · merge · --files-from 등)
|
||||
/// 이 있으면 실제 rsync 가 지울 목록에 없던 것까지 지울 수 있다 ("-,s a.txt" · "H a.txt" 는 받는 쪽 a.txt 를 지움).
|
||||
/// 저절로 도는 맞추기는 지우지 않고 보내기만 하고, 지우기는 카드의 [지금 지우기 (확인)] 로 한 번씩만. 값은 그 규칙들
|
||||
final noAutoDelete = <String, List<String>>{};
|
||||
final _deleteOnce = <String>{};
|
||||
|
||||
/// 이 쌍의 지우기에 걸리는 미리 볼 수 없는 거르기 규칙 (지우기 포함 rsync 쌍만)
|
||||
List<String> unsupportedOf(LiveSyncPair x) => x.delete ? _filterOf(x).unsupported : const [];
|
||||
|
||||
/// 미리 볼 수 없는 규칙이 있는 쌍의 "맞출 것" 은 지우기 없이 센다 (저절로 지우지 않으므로 − 줄이 늘 남지 않게)
|
||||
LiveSyncPair _pendingPair(LiveSyncPair x) => unsupportedOf(x).isNotEmpty ? x.copyWith(delete: false) : x;
|
||||
|
||||
/// [지금 지우기] 창에 보일 지울 것 (미리 본 것 - 위 규칙 때문에 실제와 다를 수 있음) · 그 가운데 --delete-excluded 로 지워지는 것
|
||||
Future<(List<String>, List<String>)> previewDeletes(LiveSyncPair x) async {
|
||||
final excl = <String>[];
|
||||
final del = [
|
||||
for (final d in await diff(x.copyWith(delete: true), limit: 100000, filter: _filterOf(x), excluded: excl))
|
||||
if (d.startsWith('− ')) d.substring(2)
|
||||
];
|
||||
return (del, excl);
|
||||
}
|
||||
|
||||
/// 확인 창 (FilterLimitNote 와 체크) 을 거친 뒤 이번 한 번만 지우기 포함으로 맞춘다. 다음 자동 맞추기는 다시 보내기만
|
||||
Future<void> deleteNow(LiveSyncPair x) async {
|
||||
_deleteOnce.add(keyOf(x));
|
||||
await syncNow(x);
|
||||
}
|
||||
|
||||
/// 받는 쪽에만 있는 [path] (상대 경로 [r]) 가 지워지는 모양을 [out] 에 "− 경로" 로 (rsync · _mirrorV 와 같게):
|
||||
/// 거른 것은 지우지 않고 (--delete-excluded 면 지움), 안에 거른 것이 든 폴더는 남기고 그 안의 거르지 않은 것만.
|
||||
/// 10/20 (15, 28 의 장면으로 찾음): 예전에는 그런 폴더도 "− 폴더" 한 줄이라 지울지 묻는 창에 폴더가 통째로 보이고,
|
||||
/// 맞춘 뒤에도 (폴더가 남으므로) 맞출 것에 계속 남았다.
|
||||
/// [excluded] 가 있으면 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 을 거기에도 (확인 창에서 따로 묶음)
|
||||
static Future<void> _gone(String path, String r, bool isDir, RsyncFilter filter, List<String> out, int limit,
|
||||
[List<String>? excluded]) async {
|
||||
if (out.length >= limit) return;
|
||||
// 관리자 지시 (10/20): 받는 쪽 규칙 (- · P · R · ,r - --delete-excluded 면 쪽을 적지 않은 규칙은 빠짐) 에 걸린 것만 지킨다
|
||||
if (filter.protects(r, isDir: isDir)) return;
|
||||
Future<void> mark() async {
|
||||
// --delete-excluded 때문에 지워지는 것: 보내는 쪽에서 거른 것 · 그런 것이 든 폴더
|
||||
if (excluded != null &&
|
||||
filter.deleteExcluded &&
|
||||
(filter.excluded(r, isDir: isDir) || (isDir && await _hasExcluded(path, r, filter, sender: true)))) {
|
||||
excluded.add(r);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDir || !filter.protectsAny || !await _hasExcluded(path, r, filter)) {
|
||||
out.add('− $r');
|
||||
await mark();
|
||||
return;
|
||||
}
|
||||
List<({String path, bool isDir, int size, DateTime modified})> items;
|
||||
try {
|
||||
items = await vList(path);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
for (final e in items) {
|
||||
final name = vBasename(e.path);
|
||||
if (name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
||||
await _gone(e.path, '$r/$name', e.isDir, filter, out, limit, excluded);
|
||||
}
|
||||
}
|
||||
|
||||
/// 폴더 [dir] (상대 경로 [rel]) 안에 받는 쪽에서 지키는 것 ([sender] 면 보내는 쪽에서 거른 것) 이 하나라도 있는지
|
||||
static Future<bool> _hasExcluded(String dir, String rel, RsyncFilter filter, {bool sender = false}) async {
|
||||
List<({String path, bool isDir, int size, DateTime modified})> items;
|
||||
try {
|
||||
items = await vList(dir);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
for (final e in items) {
|
||||
final r = '$rel/${vBasename(e.path)}';
|
||||
if (sender ? filter.excluded(r, isDir: e.isDir) : filter.protects(r, isDir: e.isDir)) return true;
|
||||
if (e.isDir && await _hasExcluded(e.path, r, filter, sender: sender)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// [excluded]: 지울 것 가운데 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 을 모은다 ([_gone])
|
||||
static Future<List<String>> diff(LiveSyncPair x,
|
||||
{int limit = 500, RsyncFilter filter = RsyncFilter.none, List<String>? excluded}) async {
|
||||
if (isDav(x.source) || isDav(x.target)) return _diffV(x, limit: limit, filter: filter, excluded: excluded);
|
||||
static Future<List<String>> diff(LiveSyncPair x, {int limit = 500}) async {
|
||||
if (isDav(x.source) || isDav(x.target)) return _diffV(x, limit: limit);
|
||||
final out = <String>[];
|
||||
Future<void> walk(String src, String dst, String rel) async {
|
||||
if (out.length >= limit) return;
|
||||
|
|
@ -278,11 +165,8 @@ class LiveSync extends ChangeNotifier {
|
|||
if (out.length >= limit) return;
|
||||
final name = p.basename(e.path);
|
||||
if (isAppTempName(name)) continue; // 78 보충: 이름 바꾸는 중의 임시 이름은 맞출 것이 아니다
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
// 10/20 (15, 28 의 장면으로 찾음): rsync 쌍은 옵션의 거르기를 따른다 - 예전에는 rsync 가 보내지 않는 것을
|
||||
// 맞출 것으로 늘 세고, 지우지 않는 것 (받는 쪽의 거른 것) 을 지울지 물었다
|
||||
if (filter.excluded(r, isDir: e is Directory)) continue;
|
||||
names.add(name);
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
final t = p.join(dst, name);
|
||||
if (e is Directory) {
|
||||
await walk(e.path, t, r);
|
||||
|
|
@ -300,8 +184,7 @@ class LiveSync extends ChangeNotifier {
|
|||
if (x.delete && await Directory(dst).exists()) {
|
||||
await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) {
|
||||
final name = p.basename(e.path);
|
||||
if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
||||
await _gone(e.path, rel.isEmpty ? name : '$rel/$name', e is Directory, filter, out, limit, excluded);
|
||||
if (!names.contains(name) && !name.endsWith('.jjsync') && !isAppTempName(name)) out.add('− ${rel.isEmpty ? name : '$rel/$name'}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -311,8 +194,7 @@ class LiveSync extends ChangeNotifier {
|
|||
}
|
||||
|
||||
/// [diff] 의 WebDAV 판 (한쪽이라도 dav://): 크기가 다르거나, 대상이 원본보다 옛것이면
|
||||
static Future<List<String>> _diffV(LiveSyncPair x,
|
||||
{int limit = 500, RsyncFilter filter = RsyncFilter.none, List<String>? excluded}) async {
|
||||
static Future<List<String>> _diffV(LiveSyncPair x, {int limit = 500}) async {
|
||||
final out = <String>[];
|
||||
Future<void> walk(String src, String dst, String rel) async {
|
||||
if (out.length >= limit) return;
|
||||
|
|
@ -333,25 +215,20 @@ class LiveSync extends ChangeNotifier {
|
|||
if (out.length >= limit) return;
|
||||
final name = vBasename(e.path);
|
||||
if (isAppTempName(name)) continue; // 78 보충
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
if (filter.excluded(r, isDir: e.isDir)) continue; // 관리자 지시 (10/19): 맞추기 (_mirrorV) 와 같은 거르기
|
||||
names.add(name);
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
if (e.isDir) {
|
||||
await walk(e.path, vJoin(dst, name), r);
|
||||
continue;
|
||||
}
|
||||
final t = there[name];
|
||||
// M1: 받는 쪽이 같은 이름의 폴더면 지우기 포함일 때만 바뀐다 (아니면 건너뜀 - 기다리는 것으로 세지 않음)
|
||||
if (t != null && t.isDir && !x.delete) continue;
|
||||
if (t == null || t.isDir || t.size != e.size || t.modified.isBefore(e.modified.subtract(const Duration(seconds: 2)))) {
|
||||
out.add(r);
|
||||
}
|
||||
}
|
||||
if (x.delete) {
|
||||
for (final t in there.entries) {
|
||||
final name = t.key;
|
||||
if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
||||
await _gone(t.value.path, rel.isEmpty ? name : '$rel/$name', t.value.isDir, filter, out, limit, excluded);
|
||||
for (final name in there.keys) {
|
||||
if (!names.contains(name) && !name.endsWith('.jjsync') && !isAppTempName(name)) out.add('− ${rel.isEmpty ? name : '$rel/$name'}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -371,8 +248,8 @@ class LiveSync extends ChangeNotifier {
|
|||
problems[k] = SourceUnreadableException(x.source, cause: e);
|
||||
}
|
||||
try {
|
||||
pending[k] = await diff(_pendingPair(x), filter: _filterOf(x));
|
||||
await _countOnlyTarget(_pendingPair(x));
|
||||
pending[k] = await diff(x);
|
||||
await _countOnlyTarget(x);
|
||||
} catch (_) {}
|
||||
notifyListeners();
|
||||
}
|
||||
|
|
@ -388,7 +265,7 @@ class LiveSync extends ChangeNotifier {
|
|||
}
|
||||
// 지우기 포함으로 셌을 때 "지울 것" (− ) 이 곧 대상에만 있는 것
|
||||
try {
|
||||
onlyTarget[k] = [for (final d in await diff(x.copyWith(delete: true), limit: 10000, filter: _filterOf(x))) if (d.startsWith('− ')) d].length;
|
||||
onlyTarget[k] = [for (final d in await diff(x.copyWith(delete: true), limit: 10000)) if (d.startsWith('− ')) d].length;
|
||||
} catch (_) {
|
||||
onlyTarget.remove(k); // 세지 못하면 예전 글
|
||||
}
|
||||
|
|
@ -399,10 +276,6 @@ class LiveSync extends ChangeNotifier {
|
|||
/// 지우기를 확인하지 않은 쌍: 대상에만 있어 지워질 항목 (원본 기준 상대 경로). 확인 창에 보여 준다 (42)
|
||||
final toDelete = <String, List<String>>{};
|
||||
|
||||
/// [toDelete] 가운데 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더). 확인 창에서 따로 묶어 보인다
|
||||
/// (관리자 3a 조건 10/20: 사용자가 알고 누르게)
|
||||
final toDeleteExcluded = <String, List<String>>{};
|
||||
|
||||
/// 원본을 읽지 못했거나 원본이 비어 멈춘 쌍 (68 · 70). 성공하면 지운다. 카드에 빨갛게 · 작업 알림에도
|
||||
final problems = <String, SourceUnreadableException>{};
|
||||
|
||||
|
|
@ -425,10 +298,8 @@ class LiveSync extends ChangeNotifier {
|
|||
|
||||
/// 지울 목록을 보고 결정: [delete] true = 지우기 포함으로 맞추기 (확인함), false = 지우기 끄기. 그 뒤 바로 맞춘다
|
||||
Future<void> decideDelete(LiveSyncPair x, {required bool delete}) async {
|
||||
final now = delete ? x.copyWith(deleteConfirmed: true, confirmedOptions: _optionsOf(x)) : x.copyWith(delete: false);
|
||||
final now = delete ? x.copyWith(deleteConfirmed: true) : x.copyWith(delete: false);
|
||||
toDelete.remove(keyOf(x));
|
||||
toDeleteExcluded.remove(keyOf(x));
|
||||
reasked.remove(keyOf(x));
|
||||
await _setPair(x, now);
|
||||
await syncNow(now);
|
||||
}
|
||||
|
|
@ -511,15 +382,9 @@ class LiveSync extends ChangeNotifier {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 rsync 옵션 (있으면 맞추지 않는다 - 카드에 빨갛게)
|
||||
List<OptionProblem> unreadableOf(LiveSyncPair x) => optionProblems(_optionsOf(x));
|
||||
|
||||
Future<void> syncNow(LiveSyncPair x) async {
|
||||
final k = keyOf(x);
|
||||
final bad = unreadableOf(x);
|
||||
final nest = bad.isNotEmpty
|
||||
? optionProblemMessage(bad)
|
||||
: nestingProblem(x, allowInnerToOuter: c.settings.allowInnerToOuter);
|
||||
final nest = nestingProblem(x, allowInnerToOuter: c.settings.allowInnerToOuter);
|
||||
if (nest != null) {
|
||||
status[k] = (DateTime.now(), nest);
|
||||
notifyListeners();
|
||||
|
|
@ -535,67 +400,35 @@ class LiveSync extends ChangeNotifier {
|
|||
final asked = x;
|
||||
try {
|
||||
// 42: 지우기를 아직 확인하지 않았으면, 지울 것이 있을 때 지우지 않고 맞추고 확인을 기다린다 (목록은 [toDelete])
|
||||
final once = _deleteOnce.remove(k);
|
||||
final limits = unsupportedOf(x);
|
||||
if (limits.isEmpty) {
|
||||
noAutoDelete.remove(k);
|
||||
} else {
|
||||
noAutoDelete[k] = limits;
|
||||
}
|
||||
// 미리 볼 수 없는 규칙이 있으면 묻지도 않는다 (묻는 목록이 실제와 달라 - 28: 묻는 목록 밖의 a.txt 가 지워짐 · P 는 지킨 것을 끝없이 물음)
|
||||
var ask = limits.isEmpty && x.delete && !x.deleteConfirmed;
|
||||
if (limits.isEmpty && x.delete && x.deleteConfirmed && x.confirmedOptions != _optionsOf(x)) {
|
||||
if (await _deletesGrew(x)) {
|
||||
ask = true;
|
||||
reasked.add(k);
|
||||
} else {
|
||||
final now = x.copyWith(confirmedOptions: _optionsOf(x));
|
||||
await _setPair(x, now);
|
||||
x = now;
|
||||
}
|
||||
}
|
||||
if (ask) {
|
||||
final excl = <String>[];
|
||||
final del = [
|
||||
for (final d in await diff(x, limit: 100000, filter: _filterOf(x), excluded: excl)) if (d.startsWith('− ')) d.substring(2)
|
||||
];
|
||||
if (x.delete && !x.deleteConfirmed) {
|
||||
final del = [for (final d in await diff(x, limit: 100000)) if (d.startsWith('− ')) d.substring(2)];
|
||||
if (del.isEmpty) {
|
||||
final now = x.copyWith(deleteConfirmed: true, confirmedOptions: _optionsOf(x));
|
||||
await _setPair(x, now);
|
||||
x = now;
|
||||
reasked.remove(k);
|
||||
await _setPair(x, x.copyWith(deleteConfirmed: true));
|
||||
} else {
|
||||
toDelete[k] = del;
|
||||
toDeleteExcluded[k] = excl;
|
||||
x = x.copyWith(delete: false);
|
||||
}
|
||||
} else {
|
||||
toDelete.remove(k);
|
||||
toDeleteExcluded.remove(k);
|
||||
reasked.remove(k);
|
||||
}
|
||||
if (limits.isNotEmpty && !once) x = x.copyWith(delete: false); // 보내기만
|
||||
// 지우기 포함이면 원본을 확실히 읽을 수 있을 때만 (rsync --delete · robocopy /PURGE 도). 못 읽으면 멈추고 알린다
|
||||
final allowEmpty = _allowEmpty.remove(k);
|
||||
if (x.delete) await ensureSourceForDelete(x.source, x.target, allowEmpty: allowEmpty);
|
||||
// 한쪽이라도 WebDAV 면 rsync · robocopy 대신 앱이 맞춘다 (크기 · 시각 비교)
|
||||
final method = isDav(x.source) || isDav(x.target) ? CopyMethod.builtin : CopyMethod.of(x.method);
|
||||
// M1 (10/19): 원격 쌍에서 받는 쪽의 같은 이름 폴더는 지우기 포함 (--delete) 이 아니면 지우지 않고 건너뛴다 - 카드에 알린다
|
||||
final ops = FileOps(bandwidthKBps: s.copyBandwidthKBps);
|
||||
final n = switch (method) {
|
||||
CopyMethod.builtin => await ops.mirror(x.source, x.target, delete: x.delete, allowEmptySource: allowEmpty, filter: _filterOf(x)),
|
||||
CopyMethod.builtin => await FileOps(bandwidthKBps: s.copyBandwidthKBps)
|
||||
.mirror(x.source, x.target, delete: x.delete, allowEmptySource: allowEmpty),
|
||||
CopyMethod.rsync => await _rsync(x, s),
|
||||
CopyMethod.robocopy => await _robocopy(x, s),
|
||||
};
|
||||
status[k] = toDelete[k] != null
|
||||
? (DateTime.now(), trf('지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요', [toDelete[k]!.length]))
|
||||
: ops.dirConflicts.isNotEmpty
|
||||
? (DateTime.now(), trf('{0}개 맞춤 · 같은 이름의 폴더가 있어 건너뜀 {1}개', [n < 0 ? 0 : n, ops.dirConflicts.length]))
|
||||
: (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n]));
|
||||
: (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n]));
|
||||
problems.remove(k);
|
||||
emptyDeletes.remove(k);
|
||||
pending[k] = await diff(_pendingPair(asked), filter: _filterOf(asked));
|
||||
await _countOnlyTarget(_pendingPair(asked));
|
||||
pending[k] = await diff(asked);
|
||||
await _countOnlyTarget(asked);
|
||||
if (n != 0) c.note(trf('실시간 동기화 ({0}): {1} → {2}', [method.label, x.source, x.target]));
|
||||
} on SourceUnreadableException catch (e) {
|
||||
// 68 · 70: 대상은 건드리지 않고 멈춤. 카드에 빨갛게 · 작업 알림에 · 로그에
|
||||
|
|
@ -610,7 +443,7 @@ class LiveSync extends ChangeNotifier {
|
|||
);
|
||||
if (e.empty) {
|
||||
try {
|
||||
emptyDeletes[k] = [for (final d in await diff(asked, limit: 100000, filter: _filterOf(asked))) if (d.startsWith('− ')) d.substring(2)];
|
||||
emptyDeletes[k] = [for (final d in await diff(asked, limit: 100000)) if (d.startsWith('− ')) d.substring(2)];
|
||||
} catch (_) {}
|
||||
} else {
|
||||
emptyDeletes.remove(k);
|
||||
|
|
@ -631,15 +464,7 @@ class LiveSync extends ChangeNotifier {
|
|||
if (exe == null) throw StateError(tr('rsync 가 없습니다 (환경 설정 > 파일 탐색기에서 내려받기 · 경로 지정)'));
|
||||
final args = rsyncArgsFor(x, s, windows: Platform.isWindows);
|
||||
await Directory(x.target).create(recursive: true);
|
||||
// P0 (10/20): Windows 의 rsync 는 응답 파일 · MSYS=noglob 으로 (' · * 를 런타임이 다시 읽지 않게 - [prepareRsyncLaunch])
|
||||
final launch = await prepareRsyncLaunch(args);
|
||||
final ProcessResult r;
|
||||
try {
|
||||
r = await Process.run(exe, launch.args,
|
||||
workingDirectory: launch.workingDirectory, environment: launch.environment, stdoutEncoding: utf8, stderrEncoding: utf8);
|
||||
} finally {
|
||||
launch.dispose();
|
||||
}
|
||||
final r = await Process.run(exe, args, stdoutEncoding: utf8, stderrEncoding: utf8);
|
||||
if (!rsyncOk(r.exitCode)) throw ProcessException(exe, const [], '${r.stderr}'.trim(), r.exitCode);
|
||||
return RsyncOutput().feed('${r.stdout}\n').length;
|
||||
}
|
||||
|
|
@ -648,20 +473,14 @@ class LiveSync extends ChangeNotifier {
|
|||
/// 41 · 42 의 확인을 건너뛰지 않게 뺀다 (97)
|
||||
static List<String> rsyncArgsFor(LiveSyncPair x, AppSettings s, {required bool windows}) => [
|
||||
// 107: 원본을 지우는 옵션 (--remove-source-files) 도 실시간 동기화에서는 쓰지 않는다
|
||||
// 관리자 판단 (10/20, 28 확인): 목록 · 기록 · 배치 파일의 C:\… 는 rsync 가 읽는 꼴로 (rsyncFileOptionArgs)
|
||||
...rsyncFileOptionArgs([
|
||||
for (final o in splitOptions(s.rsyncOptions))
|
||||
if (o != '-P' && o != '--progress' && !isRsyncDeleteOption(o) && !isSourceRemovingOption(o)) o,
|
||||
], windows: windows),
|
||||
...splitOptions(s.rsyncOptions)
|
||||
.where((o) => o != '-P' && o != '--progress' && !isRsyncDeleteOption(o) && !isSourceRemovingOption(o)),
|
||||
if (!windows) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음)
|
||||
if (s.copyBandwidthKBps > 0) '--bwlimit=${s.copyBandwidthKBps}',
|
||||
if (x.delete) '--delete',
|
||||
// 10/20 (15, 28 의 장면으로 찾음): --delete-excluded 는 쌍이 지우기 포함일 때만 남긴다 - 예전에는 늘 빼서, 지울지 묻는 창
|
||||
// (diff 는 옵션의 거르기를 따름) 에 보인 거른 것을 실제로는 지우지 않고 맞출 것에 계속 남았다 (원격 쌍 · Rsync 화면은 따름)
|
||||
if (x.delete && splitOptions(s.rsyncOptions).contains('--delete-excluded')) '--delete-excluded',
|
||||
...appTempRsyncExcludes, // 78 보충: 이름 바꾸는 중의 임시 이름은 보내지도 지우지도 않는다
|
||||
'${toCygwinPath(x.source, windows: windows)}/',
|
||||
'${rsyncDestPath(x.target, windows: windows)}/', // 받는 쪽 UNC 는 \\서버\… (28 · 15 확인 10/20)
|
||||
'${toCygwinPath(x.target, windows: windows)}/',
|
||||
];
|
||||
|
||||
/// robocopy 인수 (실시간 동기화). 지우기 (/MIR · /PURGE) 는 쌍의 "지우기 포함" 으로만 (/MIR 는 /E 로) - 97
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import '../core/dns_resolver.dart';
|
|||
import '../core/download_detect.dart';
|
||||
import '../core/encode_options.dart';
|
||||
import '../core/playlist.dart';
|
||||
import '../core/ftp.dart';
|
||||
import '../core/sftp.dart';
|
||||
import '../core/smb.dart';
|
||||
import '../core/webdav.dart';
|
||||
|
|
@ -19,7 +18,6 @@ import '../services/image_ai.dart' show AiService;
|
|||
import '../services/app_shell.dart' show appIconOf;
|
||||
import '../services/secret_store.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import '../core/remote_idle.dart';
|
||||
|
||||
/// MKV 세부 정보의 이동 버튼 하나: 표시 이름 · 옮길 폴더
|
||||
class MoveTarget {
|
||||
|
|
@ -80,28 +78,21 @@ class LiveSyncPair {
|
|||
/// "지우기" 를 사용자가 지울 목록을 보고 확인했는지. 확인 전에는 지울 것이 있으면 지우지 않고 맞추며 확인을 기다린다 (42).
|
||||
/// 이 값이 생기기 전부터 돌던 쌍은 확인한 것으로 본다.
|
||||
final bool deleteConfirmed;
|
||||
|
||||
/// 지우기를 확인할 때의 rsync 옵션 (rsync 쌍만, 그 밖은 ''). 확인한 뒤 옵션이 바뀌어 지울 것이 늘면 다시 묻는다
|
||||
/// (관리자 3a 판단 10/20 - --delete-excluded · --exclude · --filter 등). null = 이 값이 생기기 전에 확인한 쌍
|
||||
final String? confirmedOptions;
|
||||
const LiveSyncPair(this.source, this.target,
|
||||
{this.method = 'builtin',
|
||||
this.delete = false,
|
||||
this.enabled = true,
|
||||
this.schedule = const [],
|
||||
this.deleteConfirmed = false,
|
||||
this.confirmedOptions});
|
||||
this.deleteConfirmed = false});
|
||||
|
||||
/// [delete] 를 새로 켜면 확인은 다시 받는다
|
||||
LiveSyncPair copyWith(
|
||||
{String? method, bool? delete, bool? enabled, List<String>? schedule, bool? deleteConfirmed, String? confirmedOptions}) =>
|
||||
LiveSyncPair copyWith({String? method, bool? delete, bool? enabled, List<String>? schedule, bool? deleteConfirmed}) =>
|
||||
LiveSyncPair(source, target,
|
||||
method: method ?? this.method,
|
||||
delete: delete ?? this.delete,
|
||||
enabled: enabled ?? this.enabled,
|
||||
schedule: schedule ?? this.schedule,
|
||||
deleteConfirmed: deleteConfirmed ?? (delete == true && !this.delete ? false : this.deleteConfirmed),
|
||||
confirmedOptions: confirmedOptions ?? this.confirmedOptions);
|
||||
deleteConfirmed: deleteConfirmed ?? (delete == true && !this.delete ? false : this.deleteConfirmed));
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'source': source,
|
||||
|
|
@ -111,7 +102,6 @@ class LiveSyncPair {
|
|||
'enabled': enabled,
|
||||
'schedule': schedule,
|
||||
'deleteConfirmed': deleteConfirmed,
|
||||
if (confirmedOptions != null) 'confirmedOptions': confirmedOptions,
|
||||
};
|
||||
|
||||
factory LiveSyncPair.fromJson(Map<Object?, Object?> j) => LiveSyncPair(
|
||||
|
|
@ -123,7 +113,6 @@ class LiveSyncPair {
|
|||
schedule: [for (final x in (j['schedule'] as List?) ?? const []) '$x'],
|
||||
// 이 값이 없는 예전 쌍은 이미 지우기 포함으로 돌던 것 → 확인한 것으로
|
||||
deleteConfirmed: j['deleteConfirmed'] as bool? ?? true,
|
||||
confirmedOptions: j['confirmedOptions'] is String ? j['confirmedOptions'] as String : null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -296,9 +285,6 @@ class AppSettings {
|
|||
/// 다운로드가 끝난 동영상을 편집 목록(MKV 만들기)에 자동으로 추가
|
||||
bool addFinishedDownloads = true;
|
||||
|
||||
/// 마그넷 · 토렌트가 이 초 동안 메타데이터 · 피어를 못 받으면 그 줄에 이유 후보 · [다시 시도] (0 = 알리지 않음, 관리자 지시 10/20)
|
||||
int torrentStallSeconds = 60;
|
||||
|
||||
/// OpenSubtitles (인터넷 자막 검색) - 키는 필수, 아이디·비밀번호는 선택 (받기 횟수 늘어남)
|
||||
String openSubtitlesKey = '';
|
||||
String openSubtitlesUser = '';
|
||||
|
|
@ -410,34 +396,6 @@ class AppSettings {
|
|||
/// 창: 'dual' 두 창 (기본) · 'split' 왼쪽 폴더 트리 + 오른쪽 그 폴더의 파일 목록 · 'single' 한 창
|
||||
String explorerLayout = 'auto';
|
||||
|
||||
/// X-plore 비교 a: 한 창일 때 복사 · 이동 - 'clip' 담아 두었다가 붙여넣기 (지금 그대로, 처음 값) /
|
||||
/// 'other' 숨은 다른 창의 폴더로 바로 (X-plore 처럼, 위에 "갈 곳" 줄). 예전 동작과 달라 처음 값은 그대로 (관리자 결정)
|
||||
String explorerOnePaneCopy = 'clip';
|
||||
|
||||
/// X-plore 비교 d: 펼쳐 둔 지금 폴더를 다시 누르면 접기 (X-plore 처럼). 처음 값 끔 - 189 (관리자 지시: 이름으로는 접지 않아
|
||||
/// 다시 · 두 번 눌러도 아래 줄이 움직이지 않게) 와 부딪혀 설정으로만. 켜도 두 번 누르기의 두 번째는 접지 않는다
|
||||
bool explorerRetapCollapse = false;
|
||||
|
||||
/// X-plore 비교 i: 일괄 이름 바꾸기의 마지막 규칙 (다음에 창이 그 값으로 열림, 비면 처음 값)
|
||||
Map<String, dynamic> batchRenameLast = {};
|
||||
|
||||
/// X-plore 비교 c: X-plore 스타일에서 저장 장치 · 서버 · 즐겨찾기를 트리의 맨 위 항목으로 (위 줄 탭 · ★ 는 그대로 함께).
|
||||
/// 처음 값 켬 (X-plore 스타일에서만 보임 - 다른 스타일은 그대로)
|
||||
bool explorerRootsInTree = true;
|
||||
|
||||
/// X-plore 비교 ⚖ (관리자 결정 10/18): X-plore 스타일에서 줄 끝 표시 동그라미를 늘 보이기 (처음 값 켬). 다른 스타일은 선택 모드에서만 (10/5)
|
||||
bool explorerAlwaysMarks = true;
|
||||
|
||||
/// X-plore 비교 ⚖: 폴더 표시 규칙 - 'tree' 자신 + 안의 것 전체 (지금, 처음 값 - 고르는 범위가 바뀌면 지우기 · 이동 대상이 바뀌어 그대로) /
|
||||
/// 'xplore' 1번 = 폴더 자신, 2번 = 안의 파일 전체, 3번 = 풀기
|
||||
String explorerFolderMarkRule = 'tree';
|
||||
|
||||
/// X-plore 비교 ⚖: X-plore 스타일 탐색기 안에서 옆으로 밀기 = 창 바꾸기 (설정으로만, 처음 값 끔 - 10/8 사용자 결정 "화면 가운데를 밀면 화면 이동" 을 지킴, 관리자 판단 10/18)
|
||||
bool explorerSwipePanes = false;
|
||||
|
||||
/// X-plore 비교 e (관리자 결정): Android 앱 휴지통 보관 일 (처음 14일, 0 = 끔 - 예전처럼 바로 영구 삭제). Windows 는 시스템 휴지통
|
||||
int androidTrashDays = 14;
|
||||
|
||||
/// 누르기: 'select' 한 번 = 선택 · 두 번 = 열기 (기본) / 'open' 한 번 = 바로 열기.
|
||||
/// 길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴.
|
||||
String explorerClick = Platform.isAndroid ? 'open' : 'select';
|
||||
|
|
@ -473,23 +431,10 @@ class AppSettings {
|
|||
/// 85: 탐색기의 복사 · 이동 진행 패널을 숨김 (아래 작은 표시만). 처음 값: 보임
|
||||
bool explorerProgressHidden = false;
|
||||
|
||||
/// 89: Wi-Fi 공유 - 저절로 끄는 시간 (분, 0 = 끄지 않음). 처음 값 30분 (관리자 지시 10/17)
|
||||
int wifiShareMinutes = 30;
|
||||
|
||||
/// 89 · 보안 검토 3: Wi-Fi 공유를 Tailscale (100.64/10, 내 tailnet 의 기기) 주소에서도 연다 (관리자 결정 10/18: 처음 켬)
|
||||
bool wifiShareTailscale = true;
|
||||
|
||||
/// 89: Wi-Fi 공유로 마지막에 고른 폴더 (다시 켤 때 처음 고른 것)
|
||||
String wifiShareFolder = '';
|
||||
|
||||
/// 86: ZIP 으로 묶기의 압축 정도 (1 빠르게 · 6 보통 (처음 값) · 9 작게) - 마지막에 고른 것을 기억
|
||||
int zipLevel = 6;
|
||||
bool explorerShowHidden = false;
|
||||
|
||||
/// X-plore 숨기기 (관리자 지시 10/18): 파일 탐색기 목록에서 숨긴 파일 · 폴더의 경로 (파일 자체는 바꾸지 않는다).
|
||||
/// 지우기 · 이동 대상에서는 빠지지 않는다. 목록 맨 아래 [숨긴 항목 보이기] 로 다시 본다
|
||||
List<String> explorerHiddenItems = [];
|
||||
|
||||
// ── 복사 · 이동 (파일 탐색기) · rsync · lsync (Rsync 화면) ──
|
||||
/// 파일 탐색기의 복사 · 이동 방법: 'builtin' 현재 방식 (기본) · 'robocopy' (Windows). 파일만 고를 때 / 폴더가 들어 있을 때.
|
||||
/// rsync 는 Rsync 화면에서 따로 (파일 탐색기에서는 쓰지 않음)
|
||||
|
|
@ -615,17 +560,6 @@ class AppSettings {
|
|||
/// 88: Android SMB 서버 (smbj). 비밀번호는 안전 저장소에만. Windows 는 운영체제의 \서버\공유 를 쓴다
|
||||
List<SmbServer> smbServers = [];
|
||||
|
||||
/// 보안 검토 6 (관리자 결정 10/18): SMB 서명이나 암호화가 되지 않는 연결은 막는다. 처음 끔 (오래된 NAS 가 붙지 않을 수 있어서).
|
||||
/// 꺼 두어도 서명 · 암호화가 안 되는 서버는 탐색기 탭에 ⚠
|
||||
bool smbRequireSecure = false;
|
||||
|
||||
/// l: FTP · FTPS 서버. 비밀번호는 안전 저장소에만 (설정 파일에는 평문으로 쓰지 않는다)
|
||||
List<FtpServer> ftpServers = [];
|
||||
|
||||
/// 관리자 판단 (10/20): 원격 서버 (FTP · SFTP · SMB · WebDAV) 받기 · 올리기에서 조각 사이를 기다리는 시간 (초).
|
||||
/// 이 시간 동안 아무것도 오가지 않으면 실패 ("서버가 답하지 않습니다"). 30 · 60 (처음) · 120 · 300 - 그 밖의 값은 60
|
||||
int remoteIdleSeconds = RemoteIdle.defaultSeconds;
|
||||
|
||||
// ───────── 123 · 121: AI 그림 · 해상도 올리기 ─────────
|
||||
|
||||
/// 그리는 엔진: 'local' 이 기기 안 (기본) · 아니면 [aiServices] 의 id (사용자가 추가한 서버 · 서비스)
|
||||
|
|
@ -769,7 +703,6 @@ class AppSettings {
|
|||
'showHotkey': showHotkey,
|
||||
'askAiOptions': askAiOptions,
|
||||
'addFinishedDownloads': addFinishedDownloads,
|
||||
'torrentStallSeconds': torrentStallSeconds,
|
||||
// 비밀 값은 안전 저장소에 (54). 파일에는 저장해 두었는지만
|
||||
'openSubtitlesSaved': {
|
||||
'key': openSubtitlesKey.isNotEmpty,
|
||||
|
|
@ -820,14 +753,6 @@ class AppSettings {
|
|||
'torJavaScript': torJavaScript,
|
||||
'torNoticeSeen': torNoticeSeen,
|
||||
'explorerLayout': explorerLayout,
|
||||
'explorerOnePaneCopy': explorerOnePaneCopy,
|
||||
'explorerRetapCollapse': explorerRetapCollapse,
|
||||
'batchRenameLast': batchRenameLast,
|
||||
'explorerRootsInTree': explorerRootsInTree,
|
||||
'explorerAlwaysMarks': explorerAlwaysMarks,
|
||||
'explorerFolderMarkRule': explorerFolderMarkRule,
|
||||
'explorerSwipePanes': explorerSwipePanes,
|
||||
'androidTrashDays': androidTrashDays,
|
||||
'explorerClick': explorerClick,
|
||||
// 예전 기본값 (두 창 · 한 번 누르면 선택) 은 사용자가 고른 값이 아니므로 한 번 새 기본값으로 옮긴다
|
||||
'explorerV2': true,
|
||||
|
|
@ -842,13 +767,9 @@ class AppSettings {
|
|||
'explorerDropAction': explorerDropAction,
|
||||
'explorerProgressHidden': explorerProgressHidden,
|
||||
'zipLevel': zipLevel,
|
||||
'wifiShareMinutes': wifiShareMinutes,
|
||||
'wifiShareTailscale': wifiShareTailscale,
|
||||
'wifiShareFolder': wifiShareFolder,
|
||||
'explorerSortPerFolder': explorerSortPerFolder,
|
||||
'explorerFolderSorts': explorerFolderSorts,
|
||||
'explorerShowHidden': explorerShowHidden,
|
||||
'explorerHiddenItems': explorerHiddenItems,
|
||||
'explorerPaths': explorerPaths,
|
||||
'copyMethodFile': copyMethodFile,
|
||||
'copyConflict': copyConflict,
|
||||
|
|
@ -921,9 +842,6 @@ class AppSettings {
|
|||
// 새 비밀 값은 안전 저장소에 못 써도 평문으로 쓰지 않는다 (사용자 결정 54) - plainSecrets 여도 넣지 않음
|
||||
'sftpServers': [for (final x in sftpServers) x.toJson()],
|
||||
'smbServers': [for (final x in smbServers) x.toJson()],
|
||||
'smbRequireSecure': smbRequireSecure,
|
||||
'remoteIdleSeconds': remoteIdleSeconds,
|
||||
'ftpServers': [for (final x in ftpServers) x.toJson()],
|
||||
'rsyncOptions': rsyncOptions,
|
||||
'robocopyOptions': robocopyOptions,
|
||||
'copyRunMode': copyRunMode,
|
||||
|
|
@ -991,7 +909,6 @@ class AppSettings {
|
|||
..showHotkey = j['showHotkey'] as String? ?? 'Ctrl+Shift+X'
|
||||
..askAiOptions = j['askAiOptions'] as bool? ?? true
|
||||
..addFinishedDownloads = j['addFinishedDownloads'] as bool? ?? true
|
||||
..torrentStallSeconds = const [0, 30, 60, 120, 300].contains(j['torrentStallSeconds']) ? j['torrentStallSeconds'] as int : 60
|
||||
..openSubtitlesKey = j['openSubtitlesKey'] as String? ?? ''
|
||||
..openSubtitlesUser = j['openSubtitlesUser'] as String? ?? ''
|
||||
..openSubtitlesPassword = j['openSubtitlesPassword'] as String? ?? ''
|
||||
|
|
@ -1048,14 +965,6 @@ class AppSettings {
|
|||
..torJavaScript = j['torJavaScript'] != false
|
||||
..torNoticeSeen = j['torNoticeSeen'] == true
|
||||
// 'auto' = 넓은 화면은 두 창, 좁은 화면 (폰 세로) 은 한 창. 예전 기본 'dual' 은 한 번 'auto' 로
|
||||
..explorerOnePaneCopy = j['explorerOnePaneCopy'] == 'other' ? 'other' : 'clip'
|
||||
..explorerRetapCollapse = j['explorerRetapCollapse'] == true
|
||||
..batchRenameLast = j['batchRenameLast'] is Map ? Map<String, dynamic>.from(j['batchRenameLast'] as Map) : {}
|
||||
..explorerRootsInTree = j['explorerRootsInTree'] != false
|
||||
..explorerAlwaysMarks = j['explorerAlwaysMarks'] != false
|
||||
..explorerFolderMarkRule = j['explorerFolderMarkRule'] == 'xplore' ? 'xplore' : 'tree'
|
||||
..explorerSwipePanes = j['explorerSwipePanes'] == true
|
||||
..androidTrashDays = switch (j['androidTrashDays']) { final int d when d >= 0 && d <= 365 => d, _ => 14 }
|
||||
..explorerLayout = const ['single', 'split', 'auto'].contains(j['explorerLayout'])
|
||||
? j['explorerLayout'] as String
|
||||
: j['explorerLayout'] == 'dual' && j['explorerV2'] == true
|
||||
|
|
@ -1082,9 +991,6 @@ class AppSettings {
|
|||
..explorerSortPerFolder = j['explorerSortPerFolder'] == true
|
||||
..explorerProgressHidden = j['explorerProgressHidden'] == true
|
||||
..zipLevel = const [1, 6, 9].contains(j['zipLevel']) ? j['zipLevel'] as int : 6
|
||||
..wifiShareMinutes = const [0, 10, 30, 60, 120].contains(j['wifiShareMinutes']) ? j['wifiShareMinutes'] as int : 30
|
||||
..wifiShareTailscale = j['wifiShareTailscale'] is bool ? j['wifiShareTailscale'] as bool : true
|
||||
..wifiShareFolder = j['wifiShareFolder'] is String ? j['wifiShareFolder'] as String : ''
|
||||
..explorerDropAction = const ['ask', 'copy', 'move'].contains(j['explorerDropAction']) ? j['explorerDropAction'] as String : 'ask'
|
||||
..explorerFolderSorts = {
|
||||
if (j['explorerFolderSorts'] is Map)
|
||||
|
|
@ -1092,7 +998,6 @@ class AppSettings {
|
|||
if (e.key is String && e.value is String) e.key as String: e.value as String,
|
||||
}
|
||||
..explorerShowHidden = j['explorerShowHidden'] as bool? ?? false
|
||||
..explorerHiddenItems = [for (final x in (j['explorerHiddenItems'] as List?) ?? const []) if (x is String && x.isNotEmpty) x]
|
||||
..explorerPaths = [for (final x in (j['explorerPaths'] as List?) ?? const []) '$x']
|
||||
..copyMethodFile = _method(j['copyMethodFile'])
|
||||
..copyConflict = const ['rename', 'overwrite', 'skip'].contains(j['copyConflict']) ? j['copyConflict'] as String : 'ask'
|
||||
|
|
@ -1183,16 +1088,10 @@ class AppSettings {
|
|||
for (final x in (j['sftpServers'] as List?) ?? const [])
|
||||
if (x is Map) SftpServer.fromJson(x),
|
||||
]
|
||||
..smbRequireSecure = j['smbRequireSecure'] is bool ? j['smbRequireSecure'] as bool : false
|
||||
..remoteIdleSeconds = RemoteIdle.choices.contains(j['remoteIdleSeconds']) ? j['remoteIdleSeconds'] as int : RemoteIdle.defaultSeconds
|
||||
..smbServers = [
|
||||
for (final x in (j['smbServers'] as List?) ?? const [])
|
||||
if (x is Map) SmbServer.fromJson(x),
|
||||
]
|
||||
..ftpServers = [
|
||||
for (final x in (j['ftpServers'] as List?) ?? const [])
|
||||
if (x is Map) FtpServer.fromJson(x),
|
||||
]
|
||||
..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'
|
||||
|
|
@ -1274,12 +1173,6 @@ class AppSettings {
|
|||
for (final x in smb) x is Map ? (Map.of(x)..remove('password')) : x,
|
||||
];
|
||||
}
|
||||
final ftp = r['ftpServers'];
|
||||
if (ftp is List) {
|
||||
r['ftpServers'] = [
|
||||
for (final x in ftp) x is Map ? (Map.of(x)..remove('password')) : x,
|
||||
];
|
||||
}
|
||||
final ai = r['aiServices'];
|
||||
if (ai is List) {
|
||||
r['aiServices'] = [
|
||||
|
|
@ -1401,7 +1294,6 @@ class SettingsStore {
|
|||
static String _sftpKey(String id) => 'sftp.key.$id';
|
||||
static String _sftpKp(String id) => 'sftp.kp.$id';
|
||||
static String _smbPw(String id) => 'smb.pw.$id';
|
||||
static String _ftpPw(String id) => 'ftp.pw.$id';
|
||||
static String _aiKey(String id) => 'ai.key.$id';
|
||||
static const _davServers = 'dav.servers';
|
||||
|
||||
|
|
@ -1426,8 +1318,6 @@ class SettingsStore {
|
|||
},
|
||||
for (final x in s.smbServers)
|
||||
if (x.password.isNotEmpty) _smbPw(x.id): x.password,
|
||||
for (final x in s.ftpServers)
|
||||
if (x.password.isNotEmpty) _ftpPw(x.id): x.password,
|
||||
// 서버 목록 (비밀번호 없이): 예전 판이 설정 파일에서 서버 목록을 지워도 되살릴 수 있게 (40)
|
||||
if (s.webdavServers.isNotEmpty) _davServers: jsonEncode([for (final x in s.webdavServers) x.toJson()]),
|
||||
};
|
||||
|
|
@ -1480,9 +1370,6 @@ class SettingsStore {
|
|||
s.smbServers = [
|
||||
for (final x in s.smbServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_smbPw(x.id)] ?? ''),
|
||||
];
|
||||
s.ftpServers = [
|
||||
for (final x in s.ftpServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_ftpPw(x.id)] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/// 읽은 설정에 안전 저장소의 비밀 값을 채운다. 파일에 예전 평문 값이 있으면 저장소로 옮기고 파일에서 지운다 (54).
|
||||
|
|
@ -1687,7 +1574,7 @@ class SettingsStore {
|
|||
if (!_secretsOk) return {...values.keys.where((k) => _known[k] != values[k])};
|
||||
final unsaved = <String>{};
|
||||
for (final k in {..._known.keys, ...values.keys}) {
|
||||
if (!k.startsWith('os.') && !k.startsWith('dav.') && !k.startsWith('ai.') && !k.startsWith('sftp.') && !k.startsWith('smb.') && !k.startsWith('ftp.')) continue;
|
||||
if (!k.startsWith('os.') && !k.startsWith('dav.') && !k.startsWith('ai.') && !k.startsWith('sftp.') && !k.startsWith('smb.')) continue;
|
||||
final v = values[k];
|
||||
if (v == _known[k]) continue;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ import '../core/sync_preview.dart' show isRsyncDeleteOption;
|
|||
import '../core/sync_tools.dart';
|
||||
import '../core/vfs.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import '../core/rsync_filter.dart';
|
||||
import '../core/rsync_launch.dart';
|
||||
|
||||
/// 복사 · 이동 한 번 (파일 탐색기). 방법 (현재 방식 · rsync · robocopy) 과 상관없이 같은 진행 상태를 알린다:
|
||||
/// - 위쪽: 고른 항목 (폴더) 중 몇 번째인지 · 전체 진행률
|
||||
|
|
@ -102,9 +100,6 @@ class TransferJob extends ChangeNotifier {
|
|||
/// 같은 이름이라 건너뛴 항목 (원본 경로)
|
||||
final skipped = <String>[];
|
||||
|
||||
/// M1 (10/19): 원격 맞추기에서 받는 쪽에 같은 이름의 폴더가 있어 건너뛴 것 (--delete 가 없을 때)
|
||||
final dirConflicts = <String>[];
|
||||
|
||||
TransferJob({
|
||||
required this.sources,
|
||||
required this.dest,
|
||||
|
|
@ -269,11 +264,6 @@ class TransferJob extends ChangeNotifier {
|
|||
|
||||
Future<void> run() async {
|
||||
try {
|
||||
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션이 남으면 실행하지 않는다 (rsync 가 원본 경로로 읽어 옵션이 몰래 빠짐)
|
||||
if (method == CopyMethod.rsync) {
|
||||
final bad = optionProblems(options);
|
||||
if (bad.isNotEmpty) throw StateError(optionProblemMessage(bad));
|
||||
}
|
||||
for (var i = 0; i < sources.length; i++) {
|
||||
final s = sources[i];
|
||||
if ((isDav(s) || FileSystemEntity.isDirectorySync(s)) && isSameOrInside(dest, s)) {
|
||||
|
|
@ -350,14 +340,7 @@ class TransferJob extends ChangeNotifier {
|
|||
if (contents && (isDav(sources[i]) || FileSystemEntity.isDirectorySync(sources[i]))) {
|
||||
// 폴더 "안의 것" 맞추기 (rsync 원본/ 대상/ 과 같은 뜻): -u · --delete · 원본 파일 지우기 (--remove-source-files) 도
|
||||
settled.clear();
|
||||
await _ops!.mirror(sources[i], dest,
|
||||
update: _update || skipNewer,
|
||||
delete: _delete,
|
||||
strict: move,
|
||||
// 관리자 지시 (10/19): rsync 를 고른 작업 (원격이라 앱이 맞춤) 은 옵션의 거르기도 미리 보기와 같게
|
||||
filter: method == CopyMethod.rsync ? RsyncFilter.parse(options) : RsyncFilter.none);
|
||||
dirConflicts.addAll(_ops!.dirConflicts);
|
||||
_ops!.dirConflicts.clear();
|
||||
await _ops!.mirror(sources[i], dest, update: _update || skipNewer, delete: _delete, strict: move);
|
||||
if (move && !cancelled) await _deleteSettled();
|
||||
made.add(dest);
|
||||
} else {
|
||||
|
|
@ -402,20 +385,8 @@ class TransferJob extends ChangeNotifier {
|
|||
if (log.length > 20) log.removeAt(0);
|
||||
}
|
||||
|
||||
/// [rsync]: Windows 의 rsync 는 응답 파일 · MSYS=noglob 으로 띄운다 (P0 10/20 - ' · * 를 런타임이 다시 읽지 않게, [prepareRsyncLaunch]).
|
||||
/// robocopy 는 Windows 프로그램이라 그대로
|
||||
Future<int> _start(String exe, List<String> args, Encoding enc, void Function(String chunk) onOut, {bool rsync = false}) async {
|
||||
final launch = rsync ? await prepareRsyncLaunch(args) : null;
|
||||
try {
|
||||
return await _startRaw(exe, launch?.args ?? args, enc, onOut, workingDirectory: launch?.workingDirectory, environment: launch?.environment);
|
||||
} finally {
|
||||
launch?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> _startRaw(String exe, List<String> args, Encoding enc, void Function(String chunk) onOut,
|
||||
{String? workingDirectory, Map<String, String>? environment}) async {
|
||||
_proc = await Process.start(exe, args, runInShell: false, workingDirectory: workingDirectory, environment: environment);
|
||||
Future<int> _start(String exe, List<String> args, Encoding enc, void Function(String chunk) onOut) async {
|
||||
_proc = await Process.start(exe, args, runInShell: false);
|
||||
final a = _proc!.stdout.transform(enc.decoder).listen(onOut);
|
||||
final b = _proc!.stderr.transform(enc.decoder).listen((t) {
|
||||
for (final l in t.split(RegExp(r'[\r\n]'))) {
|
||||
|
|
@ -472,7 +443,6 @@ class TransferJob extends ChangeNotifier {
|
|||
}
|
||||
_tick();
|
||||
},
|
||||
rsync: true,
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (!rsyncOk(code)) throw ProcessException(exe, const [], rsyncFailureText(code, errLog, log), code);
|
||||
|
|
|
|||
|
|
@ -1,184 +0,0 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../l10n/tr.dart';
|
||||
import 'file_ops.dart' show isSameOrInside, samePath;
|
||||
|
||||
/// X-plore 비교 e (관리자 결정 10/18): Android 앱 휴지통. Windows 는 시스템 휴지통 그대로.
|
||||
/// - 저장 장치마다 맨 위의 `.jj_trash` 폴더 (같은 저장 장치 안에서 이름만 바꿔 옮긴다 - 빠르고 공간이 들지 않는다).
|
||||
/// - 항목마다 `<시각>_<무작위>/` 폴더에 원래 것 + `info.json` (원래 경로 · 지운 때 · 폴더인지 · 크기).
|
||||
/// - `.nomedia` 를 두어 갤러리 · 음악 앱이 휴지통의 그림 · 동영상을 보이지 않게.
|
||||
/// - 보관 일 (설정, 처음 14일) 이 지나면 지운다. 0 = 휴지통 끔 (늘 영구 삭제, 예전처럼).
|
||||
const trashFolderName = '.jj_trash';
|
||||
|
||||
class TrashItem {
|
||||
/// 휴지통 안의 항목 폴더 (`.jj_trash/<id>`)
|
||||
final String entryDir;
|
||||
|
||||
/// 원래 경로
|
||||
final String original;
|
||||
final DateTime deletedAt;
|
||||
final bool isDir;
|
||||
final int size;
|
||||
const TrashItem({required this.entryDir, required this.original, required this.deletedAt, required this.isDir, required this.size});
|
||||
|
||||
String get name => p.basename(original);
|
||||
|
||||
/// 휴지통 안에 있는 원래 것
|
||||
String get stored => p.join(entryDir, name);
|
||||
}
|
||||
|
||||
/// 휴지통으로 옮기지 못함 (다른 저장 장치 · 권한 · 공간). 부르는 쪽이 영구 삭제할지 묻는다
|
||||
class TrashMoveException implements Exception {
|
||||
final String path;
|
||||
final Object cause;
|
||||
const TrashMoveException(this.path, this.cause);
|
||||
@override
|
||||
String toString() => trf('휴지통으로 옮기지 못했습니다: {0} ({1})', [p.basename(path), cause]);
|
||||
}
|
||||
|
||||
class AppTrash {
|
||||
/// 저장 장치 맨 위들 (Android: /storage/emulated/0 · /storage/XXXX-XXXX)
|
||||
final List<String> volumeRoots;
|
||||
AppTrash(this.volumeRoots);
|
||||
|
||||
/// [path] 가 있는 저장 장치의 휴지통 폴더 (어느 저장 장치에도 없으면 null)
|
||||
String? trashDirFor(String path) {
|
||||
String? best;
|
||||
for (final r in volumeRoots) {
|
||||
if (isSameOrInside(path, r) && (best == null || r.length > best.length)) best = r;
|
||||
}
|
||||
return best == null ? null : p.join(best, trashFolderName);
|
||||
}
|
||||
|
||||
/// 휴지통 안의 것인지 (휴지통 폴더 자신 포함 - 다시 휴지통으로 보내지 않게)
|
||||
bool isInTrash(String path) {
|
||||
final t = trashDirFor(path);
|
||||
return t != null && isSameOrInside(path, t);
|
||||
}
|
||||
|
||||
static Future<int> _sizeOf(String path) async {
|
||||
final t = await FileSystemEntity.type(path, followLinks: false);
|
||||
if (t == FileSystemEntityType.file) return File(path).length();
|
||||
if (t != FileSystemEntityType.directory) return 0;
|
||||
var n = 0;
|
||||
await for (final e in Directory(path).list(recursive: true, followLinks: false).handleError((_) {})) {
|
||||
if (e is File) {
|
||||
try {
|
||||
n += await e.length();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/// [path] 를 휴지통으로 (같은 저장 장치 안 이름 바꾸기). 못 옮기면 [TrashMoveException] - 원래 것은 그대로
|
||||
Future<TrashItem> moveToTrash(String path, {DateTime? now}) async {
|
||||
final trash = trashDirFor(path);
|
||||
if (trash == null) throw TrashMoveException(path, tr('저장 장치를 알 수 없습니다'));
|
||||
final when = now ?? DateTime.now();
|
||||
final id = '${when.millisecondsSinceEpoch}_${Random().nextInt(1 << 30).toRadixString(36)}';
|
||||
final entry = Directory(p.join(trash, id));
|
||||
final isDir = await FileSystemEntity.isDirectory(path);
|
||||
final size = await _sizeOf(path);
|
||||
try {
|
||||
await entry.create(recursive: true);
|
||||
final nomedia = File(p.join(trash, '.nomedia'));
|
||||
if (!await nomedia.exists()) await nomedia.writeAsString('');
|
||||
// 정보 먼저 (옮긴 뒤 앱이 꺼져도 어디서 왔는지 남게)
|
||||
final info = TrashItem(entryDir: entry.path, original: path, deletedAt: when, isDir: isDir, size: size);
|
||||
await File(p.join(entry.path, 'info.json')).writeAsString(jsonEncode({
|
||||
'original': path,
|
||||
'deletedAt': when.toIso8601String(),
|
||||
'isDir': isDir,
|
||||
'size': size,
|
||||
}));
|
||||
if (isDir) {
|
||||
await Directory(path).rename(info.stored);
|
||||
} else {
|
||||
await File(path).rename(info.stored);
|
||||
}
|
||||
return info;
|
||||
} catch (e) {
|
||||
// 옮기지 못했으면 (이름 바꾸기가 마지막 일이라 원래 것은 제자리) 만든 항목 폴더만 지운다
|
||||
try {
|
||||
if (await entry.exists()) await entry.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
throw TrashMoveException(path, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 휴지통의 항목들 (지운 때가 최근인 것부터)
|
||||
Future<List<TrashItem>> list() async {
|
||||
final out = <TrashItem>[];
|
||||
for (final r in volumeRoots) {
|
||||
final dir = Directory(p.join(r, trashFolderName));
|
||||
if (!await dir.exists()) continue;
|
||||
await for (final e in dir.list(followLinks: false).handleError((_) {})) {
|
||||
if (e is! Directory) continue;
|
||||
try {
|
||||
final j = jsonDecode(await File(p.join(e.path, 'info.json')).readAsString()) as Map<String, dynamic>;
|
||||
out.add(TrashItem(
|
||||
entryDir: e.path,
|
||||
original: j['original'] as String,
|
||||
deletedAt: DateTime.parse(j['deletedAt'] as String),
|
||||
isDir: j['isDir'] == true,
|
||||
size: (j['size'] as num?)?.toInt() ?? 0,
|
||||
));
|
||||
} catch (_) {
|
||||
// 정보가 없는 항목 (깨짐) 은 건너뛴다 - 지우지 않는다
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => b.deletedAt.compareTo(a.deletedAt));
|
||||
return out;
|
||||
}
|
||||
|
||||
/// 되살리기: 원래 자리로. 그 자리에 같은 이름이 있으면 "이름 (되살림).확장자". 돌려준 것은 되살린 경로
|
||||
Future<String> restore(TrashItem item) async {
|
||||
var target = item.original;
|
||||
if (await FileSystemEntity.type(target, followLinks: false) != FileSystemEntityType.notFound) {
|
||||
final ext = item.isDir ? '' : p.extension(target);
|
||||
final base = item.isDir ? target : p.join(p.dirname(target), p.basenameWithoutExtension(target));
|
||||
target = '$base (${tr('되살림')})$ext';
|
||||
for (var i = 2; await FileSystemEntity.type(target, followLinks: false) != FileSystemEntityType.notFound; i++) {
|
||||
target = '$base (${tr('되살림')} $i)$ext';
|
||||
}
|
||||
}
|
||||
await Directory(p.dirname(target)).create(recursive: true);
|
||||
if (item.isDir) {
|
||||
await Directory(item.stored).rename(target);
|
||||
} else {
|
||||
await File(item.stored).rename(target);
|
||||
}
|
||||
await Directory(item.entryDir).delete(recursive: true);
|
||||
return target;
|
||||
}
|
||||
|
||||
/// 영구 삭제
|
||||
Future<void> purge(TrashItem item) => Directory(item.entryDir).delete(recursive: true);
|
||||
|
||||
/// 보관 일이 지난 것을 지운다. 지운 수
|
||||
Future<int> purgeOlderThan(int days, {DateTime? now}) async {
|
||||
final limit = (now ?? DateTime.now()).subtract(Duration(days: days));
|
||||
var n = 0;
|
||||
for (final x in await list()) {
|
||||
if (x.deletedAt.isBefore(limit)) {
|
||||
try {
|
||||
await purge(x);
|
||||
n++;
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/// 휴지통 크기 합
|
||||
Future<int> totalSize() async => (await list()).fold<int>(0, (a, b) => a + b.size);
|
||||
|
||||
/// [path] 가 휴지통 폴더 자신인지 (탐색기 목록에서 숨김 · 지우기 막기에)
|
||||
bool isTrashRoot(String path) => volumeRoots.any((r) => samePath(path, p.join(r, trashFolderName)));
|
||||
}
|
||||
|
|
@ -186,39 +186,12 @@ Future<List<ArchiveEntry>> listZip(String path) async {
|
|||
}
|
||||
|
||||
/// ZIP 풀기 - [plan] 의 파일만, 같은 이름은 [conflict] 대로
|
||||
/// zip_aes: ZIP 비밀번호를 UTF-8 바이트로 (한 바이트 = 한 글자). archive 4.3.0 은 비밀번호를 password.codeUnits 를 바이트로 잘라 써서
|
||||
/// 한글 등이 깨지고 7-Zip (UTF-8) 과 맞지 않았다 - UTF-8 바이트를 글자로 넣으면 archive 가 그 바이트를 그대로 쓴다
|
||||
String zipPasswordBytes(String password) => String.fromCharCodes(utf8.encode(password));
|
||||
|
||||
/// ZIP 풀기 ([_extractZipPlanWith]). 비밀번호에 영문 밖 글자가 있으면 UTF-8 (7-Zip · 이 앱이 만든 것) 로 먼저,
|
||||
/// 틀리면 예전 방식 (이 앱의 예전 판 · archive 그대로) 으로 한 번 더
|
||||
Future<ExtractResult> extractZipPlan(String path, String dest, ExtractPlan plan,
|
||||
{NameConflict conflict = NameConflict.rename,
|
||||
bool skipNewer = false,
|
||||
String? password,
|
||||
void Function(int done, int total)? onProgress,
|
||||
bool Function()? cancelled}) async {
|
||||
final tries = [
|
||||
if (password != null && password.isNotEmpty) zipPasswordBytes(password),
|
||||
if (password != null && password.isNotEmpty && zipPasswordBytes(password) != password) password,
|
||||
if (password == null || password.isEmpty) password,
|
||||
];
|
||||
for (var i = 0;; i++) {
|
||||
try {
|
||||
return await _extractZipPlanWith(path, dest, plan,
|
||||
conflict: conflict, skipNewer: skipNewer, password: tries[i], onProgress: onProgress, cancelled: cancelled);
|
||||
} on ArchivePasswordException catch (e) {
|
||||
if (!e.wrong || i + 1 >= tries.length) rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<ExtractResult> _extractZipPlanWith(String path, String dest, ExtractPlan plan,
|
||||
{NameConflict conflict = NameConflict.rename,
|
||||
bool skipNewer = false,
|
||||
String? password,
|
||||
void Function(int done, int total)? onProgress,
|
||||
bool Function()? cancelled}) async {
|
||||
// 비밀번호가 걸린 항목인데 비밀번호가 없으면 (암호 글자를 그대로 쓰지 않게) 먼저 묻는다
|
||||
if (plan.encrypted && (password == null || password.isEmpty)) throw const ArchivePasswordException();
|
||||
final input = InputFileStream(path);
|
||||
|
|
@ -276,7 +249,7 @@ Future<ExtractResult> _extractZipPlanWith(String path, String dest, ExtractPlan
|
|||
/// [level] 0 (묶기만) ~ 9 (가장 작게). 다 만든 뒤에만 이름을 바꾼다 (취소 · 실패면 만들다 만 파일을 지움).
|
||||
/// 돌려주는 값: (넣은 파일 수, 건너뛴 링크 수)
|
||||
Future<(int, int)> createZip(List<String> sources, String zipPath,
|
||||
{int level = 6, String? password, void Function(int done, int total)? onProgress, bool Function()? cancelled}) async {
|
||||
{int level = 6, void Function(int done, int total)? onProgress, bool Function()? cancelled}) async {
|
||||
// 넣을 파일 (상대 이름) - 링크는 세기만
|
||||
final files = <(String, String)>[];
|
||||
var links = 0;
|
||||
|
|
@ -298,8 +271,7 @@ Future<(int, int)> createZip(List<String> sources, String zipPath,
|
|||
}
|
||||
}
|
||||
final part = '$zipPath.jjpart';
|
||||
// zip_aes: [password] 가 있으면 archive 의 AES-256 (WinZip AE) - 파일마다 메모리에 올려 암호화하므로 큰 파일은 [createZipAes] 가 막는다
|
||||
final enc = ZipFileEncoder(password: password == null || password.isEmpty ? null : zipPasswordBytes(password))..create(part, level: level);
|
||||
final enc = ZipFileEncoder()..create(part, level: level);
|
||||
var done = false;
|
||||
try {
|
||||
for (final (i, (path, rel)) in files.indexed) {
|
||||
|
|
@ -325,101 +297,6 @@ Future<(int, int)> createZip(List<String> sources, String zipPath,
|
|||
|
||||
// ───────── 7z · RAR (7-Zip 25.01 실행 파일, LGPL 2.1+ · unRAR 제한 - 풀기만) ─────────
|
||||
|
||||
/// zip_aes: 영문 밖 글자가 든 비밀번호로 (archive, 메모리에서) 암호화할 수 있는 파일 하나의 크기 한도
|
||||
const aesInMemoryLimit = 512 * 1024 * 1024;
|
||||
|
||||
/// zip_aes: 영문 밖 글자가 든 비밀번호인데 [aesInMemoryLimit] 넘는 파일이 있음
|
||||
class ZipPasswordTooBigException implements Exception {
|
||||
final String name;
|
||||
const ZipPasswordTooBigException(this.name);
|
||||
@override
|
||||
String toString() => trf('비밀번호에 한글 등 영문 밖 글자가 있으면 512MB 넘는 파일 ({0}) 은 묶을 수 없습니다. 영문 · 숫자 · 기호 비밀번호를 쓰거나 파일을 나눠 주세요.', [name]);
|
||||
}
|
||||
|
||||
/// zip_aes (관리자 지시 10/17): 비밀번호 ZIP (AES-256) 을 동봉 7-Zip 으로 만든다 (Windows 7z.exe · Android lib7zz).
|
||||
/// archive 패키지의 AES 는 파일마다 통째로 메모리에 올려 암호화해 큰 동영상에서 메모리가 모자랄 수 있어 7-Zip 으로 (흘려 가며).
|
||||
/// 넣을 파일은 [createZip] 과 같게 앱이 고른다 (링크 · junction 빼고, 원본 폴더 기준 상대 이름). 비밀번호는 표준 입력으로만
|
||||
/// (명령 줄 · 작업 기록 · 프로세스 목록에 남지 않게). 다 만든 뒤에만 이름을 바꾼다 (취소 · 실패면 만들다 만 파일을 지움).
|
||||
/// 돌려주는 값: (넣은 파일 수, 건너뛴 링크 수)
|
||||
Future<(int, int)> createZipAes(String exe, List<String> sources, String zipPath,
|
||||
{required String password, int level = 6, void Function(double v)? onProgress, bool Function()? cancelled}) async {
|
||||
if (password.isEmpty) throw ArgumentError('password');
|
||||
// Windows 7-Zip 은 표준 입력으로 받은 비밀번호에 영문 밖 글자 (한글 등) 가 있으면 "매개 변수가 틀립니다" 로 실패한다
|
||||
// (코드 페이지를 바꿔도 같음, 10/17 시험). 그때는 archive 의 AES-256 으로 - 파일을 통째로 메모리에 올리므로 큰 파일은 막고 알린다 (내 판단)
|
||||
if (password.codeUnits.any((c) => c < 0x20 || c > 0x7e)) {
|
||||
for (final s in sources) {
|
||||
for (final f in [
|
||||
if (FileSystemEntity.isFileSync(s)) File(s),
|
||||
if (FileSystemEntity.isDirectorySync(s)) ...Directory(s).listSync(recursive: true, followLinks: false).whereType<File>(),
|
||||
]) {
|
||||
if (f.lengthSync() > aesInMemoryLimit) throw ZipPasswordTooBigException(p.basename(f.path));
|
||||
}
|
||||
}
|
||||
return createZip(sources, zipPath,
|
||||
level: level, password: password, cancelled: cancelled, onProgress: (d, t) => onProgress?.call(t == 0 ? 0 : d / t));
|
||||
}
|
||||
// 원본 폴더마다 (상대 이름이 그 폴더 기준이 되게) 넣을 파일 목록
|
||||
final groups = <String, List<String>>{};
|
||||
var links = 0, count = 0;
|
||||
for (final s in sources) {
|
||||
final base = p.dirname(s);
|
||||
final t = FileSystemEntity.typeSync(s, followLinks: false);
|
||||
if (t == FileSystemEntityType.link) {
|
||||
links++;
|
||||
} else if (t == FileSystemEntityType.file) {
|
||||
groups.putIfAbsent(base, () => []).add(p.relative(s, from: base));
|
||||
} else if (t == FileSystemEntityType.directory) {
|
||||
await for (final e in Directory(s).list(recursive: true, followLinks: false)) {
|
||||
if (e is Link || FileSystemEntity.isLinkSync(e.path)) {
|
||||
links++;
|
||||
} else if (e is File) {
|
||||
groups.putIfAbsent(base, () => []).add(p.relative(e.path, from: base));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (final l in groups.values) {
|
||||
count += l.length;
|
||||
}
|
||||
final part = '$zipPath.jjpart';
|
||||
final tmp = await Directory.systemTemp.createTemp('jj_7za_');
|
||||
var done = false;
|
||||
try {
|
||||
var g = 0;
|
||||
for (final MapEntry(key: base, value: rels) in groups.entries) {
|
||||
if (cancelled?.call() ?? false) throw const ArchiveCancelled();
|
||||
final list = File(p.join(tmp.path, 'list$g.txt'));
|
||||
await list.writeAsString(rels.join('\n'), encoding: utf8);
|
||||
final before = g;
|
||||
final (code, out, err) = await _run7z(
|
||||
exe,
|
||||
['a', '-tzip', '-mem=AES256', '-mcu=on', '-mx=${level.clamp(0, 9)}', '-p', '-bsp1', '-bb0', '-y', part, '@${list.path}'],
|
||||
password: password,
|
||||
confirm: true,
|
||||
workingDirectory: base,
|
||||
cancelled: cancelled, onLine: (l) {
|
||||
final m = RegExp(r'(\d+)%').firstMatch(l);
|
||||
if (m != null) onProgress?.call((before + int.parse(m[1]!) / 100) / groups.length);
|
||||
});
|
||||
if (code != 0) throw ProcessException(exe, const ['a'], err.trim().isEmpty ? out.trim() : err.trim(), code);
|
||||
g++;
|
||||
}
|
||||
if (count == 0) throw FileSystemException(tr('묶을 파일이 없습니다'), sources.first);
|
||||
done = true;
|
||||
await File(part).rename(zipPath);
|
||||
} finally {
|
||||
if (!done) {
|
||||
try {
|
||||
await File(part).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
try {
|
||||
await tmp.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
return (count, links);
|
||||
}
|
||||
|
||||
/// 7-Zip 의 "l -slt" 출력 → 목록. 링크: "Symbolic Link" · "Hard Link" 칸이 있거나 속성에 유닉스 링크 (-l…) · 재분석 지점
|
||||
List<ArchiveEntry> parse7zList(String out) {
|
||||
final entries = <ArchiveEntry>[];
|
||||
|
|
@ -449,15 +326,10 @@ List<ArchiveEntry> parse7zList(String out) {
|
|||
|
||||
/// 7-Zip 실행. 비밀번호는 명령 줄이 아니라 표준 입력으로만 (작업 기록 · 프로세스 목록에 남지 않게 - 관리자 판단)
|
||||
Future<(int, String, String)> _run7z(String exe, List<String> args,
|
||||
{String? password, void Function(String line)? onLine, bool Function()? cancelled, String? workingDirectory, bool confirm = false}) async {
|
||||
final pr = await Process.start(exe, ['-sccUTF-8', '-scsUTF-8', ...args], workingDirectory: workingDirectory);
|
||||
// -sccUTF-8 이면 7-Zip 은 표준 입력도 UTF-8 로 읽는다 - Dart 의 기본 (시스템 코드 페이지, 한국어 Windows 는 CP949) 으로 보내면
|
||||
// 한글 비밀번호가 틀렸다고 나왔다 (관리자 지시로 10/18 시험: UTF-8 + -sccUTF-8 이면 7z · RAR 이 한글 비밀번호로 풀림)
|
||||
pr.stdin.encoding = utf8;
|
||||
{String? password, void Function(String line)? onLine, bool Function()? cancelled}) async {
|
||||
final pr = await Process.start(exe, ['-sccUTF-8', '-scsUTF-8', ...args]);
|
||||
// 묻지 않을 때도 빈 줄을 보내 기다리지 않게 (암호가 걸렸는데 비밀번호가 없으면 틀림으로 끝난다)
|
||||
pr.stdin.writeln(password ?? '');
|
||||
// 만들 때는 "다시 넣기" 도 묻는다 (zip_aes)
|
||||
if (confirm) pr.stdin.writeln(password ?? '');
|
||||
await pr.stdin.close();
|
||||
final out = StringBuffer(), err = StringBuffer();
|
||||
// 끝을 기다리는 Future 는 바로 만든다 (프로세스가 끝난 뒤 asFuture 를 부르면 이미 끝난 흐름은 영영 끝나지 않았다)
|
||||
|
|
|
|||
|
|
@ -5,14 +5,11 @@ import 'file_ops.dart';
|
|||
import 'vfs.dart';
|
||||
|
||||
/// 78: 일괄 이름 바꾸기 규칙 (Total Commander · X-plore 참고).
|
||||
/// 차례: 이름 틀 ([N] 원래 이름 · [C] 번호 · [D] 날짜 · [T] 시각) → 찾아 바꾸기 → 대소문자. 파일의 확장자는 그대로 둔다.
|
||||
/// 차례: 이름 틀 ([N] 원래 이름 · [C] 번호) → 찾아 바꾸기 → 대소문자. 파일의 확장자는 그대로 둔다.
|
||||
class RenameRule {
|
||||
/// 이름 틀 (확장자 뺀 이름). [N] = 원래 이름, [C] = 번호, [D] = 날짜 (2026-10-18), [T] = 시각 (13-05-09). 예: "Show S01E[C]"
|
||||
/// 이름 틀 (확장자 뺀 이름). [N] = 원래 이름, [C] = 번호. 예: "Show S01E[C]"
|
||||
final String template;
|
||||
|
||||
/// X-plore 비교 i: [D] · [T] 의 날짜 - 'photo' 찍은 때 (사진 Exif, 없으면 바뀐 때) / 'modified' 바뀐 때
|
||||
final String dateSource;
|
||||
|
||||
/// 번호: 시작 · 간격 · 자릿수 (2 → 01, 02 …)
|
||||
final int start, step, digits;
|
||||
|
||||
|
|
@ -32,60 +29,18 @@ class RenameRule {
|
|||
this.replace = '',
|
||||
this.ignoreCase = true,
|
||||
this.caseMode = 'keep',
|
||||
this.dateSource = 'photo',
|
||||
});
|
||||
|
||||
/// X-plore 비교 i: 마지막에 쓴 규칙 기억 (설정 batchRenameLast)
|
||||
Map<String, Object> toJson() => {
|
||||
'template': template,
|
||||
'start': start,
|
||||
'step': step,
|
||||
'digits': digits,
|
||||
'find': find,
|
||||
'replace': replace,
|
||||
'ignoreCase': ignoreCase,
|
||||
'caseMode': caseMode,
|
||||
'dateSource': dateSource,
|
||||
};
|
||||
|
||||
/// 틀리거나 없는 값은 처음 값으로
|
||||
factory RenameRule.fromJson(Map<String, dynamic> j) {
|
||||
int n(String k, int d) => j[k] is int ? j[k] as int : d;
|
||||
String s(String k, String d) => j[k] is String ? j[k] as String : d;
|
||||
return RenameRule(
|
||||
template: s('template', '[N]').isEmpty ? '[N]' : s('template', '[N]'),
|
||||
start: n('start', 1),
|
||||
step: n('step', 1),
|
||||
digits: n('digits', 2).clamp(1, 9),
|
||||
find: s('find', ''),
|
||||
replace: s('replace', ''),
|
||||
ignoreCase: j['ignoreCase'] != false,
|
||||
caseMode: const ['keep', 'lower', 'upper', 'title'].contains(j['caseMode']) ? j['caseMode'] as String : 'keep',
|
||||
dateSource: j['dateSource'] == 'modified' ? 'modified' : 'photo',
|
||||
);
|
||||
}
|
||||
|
||||
/// 틀에 날짜 · 시각이 들어 있는지 (날짜를 읽어야 하는지)
|
||||
bool get usesDate => RegExp(r'\[[DT]\]', caseSensitive: false).hasMatch(template);
|
||||
|
||||
/// 바꾸는 것이 하나도 없는 규칙
|
||||
bool get isIdentity => template == '[N]' && find.isEmpty && caseMode == 'keep';
|
||||
|
||||
/// [name] (확장자 포함) 의 새 이름. [index] 는 0 부터 (번호 = start + index × step). [date] 는 [D] · [T] 에 (없으면 빈칸)
|
||||
String apply(String name, {required bool isDir, required int index, DateTime? date}) {
|
||||
/// [name] (확장자 포함) 의 새 이름. [index] 는 0 부터 (번호 = start + index × step)
|
||||
String apply(String name, {required bool isDir, required int index}) {
|
||||
final ext = isDir ? '' : p.extension(name);
|
||||
final base = isDir || ext.isEmpty ? name : name.substring(0, name.length - ext.length);
|
||||
final n = start + index * step;
|
||||
final counter = n < 0 ? '-${(-n).toString().padLeft(digits, '0')}' : n.toString().padLeft(digits, '0');
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
final day = date == null ? '' : '${date.year.toString().padLeft(4, '0')}-${two(date.month)}-${two(date.day)}';
|
||||
// 시각은 ':' 를 쓸 수 없어 (Windows) '-' 로
|
||||
final time = date == null ? '' : '${two(date.hour)}-${two(date.minute)}-${two(date.second)}';
|
||||
var out = template
|
||||
.replaceAll(RegExp(r'\[N\]', caseSensitive: false), base)
|
||||
.replaceAll(RegExp(r'\[C\]', caseSensitive: false), counter)
|
||||
.replaceAll(RegExp(r'\[D\]', caseSensitive: false), day)
|
||||
.replaceAll(RegExp(r'\[T\]', caseSensitive: false), time);
|
||||
var out = template.replaceAll(RegExp(r'\[N\]', caseSensitive: false), base).replaceAll(RegExp(r'\[C\]', caseSensitive: false), counter);
|
||||
if (find.isNotEmpty) {
|
||||
out = out.replaceAll(RegExp(RegExp.escape(find), caseSensitive: !ignoreCase), replace);
|
||||
}
|
||||
|
|
@ -115,13 +70,10 @@ class RenameRow {
|
|||
}
|
||||
|
||||
/// 미리 보기: [paths] 차례대로 번호를 매긴다. [siblings] 는 폴더마다 지금 있는 이름 (고르지 않은 것과 겹치는지 보려고)
|
||||
/// [dates] 는 [D] · [T] 에 쓰는 항목마다의 날짜 (없으면 빈칸)
|
||||
List<RenameRow> planRename(List<String> paths, RenameRule rule,
|
||||
{required bool Function(String path) isDir, required Map<String, Set<String>> siblings, Map<String, DateTime> dates = const {}}) {
|
||||
{required bool Function(String path) isDir, required Map<String, Set<String>> siblings}) {
|
||||
bool same(String a, String b) => a.toLowerCase() == b.toLowerCase(); // Windows · 대부분의 NAS 는 대소문자를 가리지 않는다
|
||||
final names = [
|
||||
for (var i = 0; i < paths.length; i++) rule.apply(vBasename(paths[i]), isDir: isDir(paths[i]), index: i, date: dates[paths[i]])
|
||||
];
|
||||
final names = [for (var i = 0; i < paths.length; i++) rule.apply(vBasename(paths[i]), isDir: isDir(paths[i]), index: i)];
|
||||
final rows = <RenameRow>[];
|
||||
for (var i = 0; i < paths.length; i++) {
|
||||
final dir = vDirname(paths[i]);
|
||||
|
|
|
|||
|
|
@ -38,26 +38,6 @@ final _youtube = RegExp(
|
|||
final _magnet = RegExp(r'magnet:\?xt=urn:bt[im]h:[^\s<>"]+', caseSensitive: false);
|
||||
final _torrentUrl = RegExp(r'https?://[^\s<>"]+\.torrent(?:\?[^\s<>"]*)?', caseSensitive: false);
|
||||
|
||||
/// 마그넷 주소의 이름 (dn=…, 주소 글 풀기 · + 는 빈칸). 마그넷이 아니거나 이름이 없으면 null.
|
||||
/// 관리자 지시 (10/20, 28 확인): 메타데이터를 받기 전에도 줄 제목에 이 이름을 보인다 (마그넷 주소 전체 대신)
|
||||
String? magnetName(String url) {
|
||||
if (!url.toLowerCase().startsWith('magnet:?')) return null;
|
||||
for (final part in url.substring(8).split('&')) {
|
||||
final i = part.indexOf('=');
|
||||
if (i <= 0 || part.substring(0, i).toLowerCase() != 'dn') continue;
|
||||
String name;
|
||||
try {
|
||||
name = Uri.decodeQueryComponent(part.substring(i + 1));
|
||||
} catch (_) {
|
||||
name = part.substring(i + 1); // 잘못 적힌 % 는 그대로
|
||||
}
|
||||
// 줄바꿈 · 제어 문자는 빈칸으로 (한 줄 제목)
|
||||
name = name.replaceAll(RegExp(r'[\x00-\x1f\x7f]+'), ' ').trim();
|
||||
if (name.isNotEmpty) return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 클립보드 글에서 다운로드할 주소 찾기 (중복 제거, 나온 순서대로)
|
||||
List<DetectedLink> detectLinks(String text) {
|
||||
final found = <int, DetectedLink>{};
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../l10n/tr.dart';
|
||||
|
||||
/// X-plore P3 보충 (관리자 지시 10/18): 파일 · 폴더의 숨김 · 읽기 전용 · 시스템 속성과 만든 때 - 보기만 (바꾸지 않음).
|
||||
/// Windows 는 GetFileAttributesExW (kernel32), 그 밖 (Android) 은 이름이 점으로 시작하면 숨김 · 만든 때는 모름.
|
||||
class FileAttributes {
|
||||
final bool hidden;
|
||||
final bool readOnly;
|
||||
final bool system;
|
||||
|
||||
/// 만든 때 (모르면 null)
|
||||
final DateTime? created;
|
||||
const FileAttributes({this.hidden = false, this.readOnly = false, this.system = false, this.created});
|
||||
|
||||
/// 화면에 보일 속성 이름들 (없으면 빈 목록)
|
||||
List<String> get labels => [if (hidden) tr('숨김'), if (readOnly) tr('읽기 전용'), if (system) tr('시스템')];
|
||||
}
|
||||
|
||||
typedef _GetAttrsExC = Int32 Function(Pointer<Utf16> name, Int32 level, Pointer<Uint8> info);
|
||||
typedef _GetAttrsExD = int Function(Pointer<Utf16> name, int level, Pointer<Uint8> info);
|
||||
|
||||
/// [path] 의 속성 (읽지 못하면 null). 로컬 경로만 (원격은 부르지 않는다)
|
||||
FileAttributes? readFileAttributes(String path) {
|
||||
if (!Platform.isWindows) return FileAttributes(hidden: p.basename(path).startsWith('.'));
|
||||
final name = path.toNativeUtf16();
|
||||
// WIN32_FILE_ATTRIBUTE_DATA: DWORD 속성 · FILETIME 만든 · 열어 본 · 바뀐 때 · DWORD 크기 (위 · 아래) = 36 바이트
|
||||
final info = calloc<Uint8>(36);
|
||||
try {
|
||||
final f = DynamicLibrary.open('kernel32.dll').lookupFunction<_GetAttrsExC, _GetAttrsExD>('GetFileAttributesExW');
|
||||
if (f(name, 0 /* GetFileExInfoStandard */, info) == 0) return null;
|
||||
final b = ByteData.sublistView(info.asTypedList(36));
|
||||
final attrs = b.getUint32(0, Endian.little);
|
||||
final ft = b.getUint32(4, Endian.little) | (b.getUint32(8, Endian.little) << 32);
|
||||
// FILETIME: 1601-01-01 부터 100ns 단위
|
||||
final created = ft == 0 ? null : DateTime.fromMicrosecondsSinceEpoch(ft ~/ 10 - 11644473600000000, isUtc: true).toLocal();
|
||||
return FileAttributes(hidden: attrs & 0x2 != 0, readOnly: attrs & 0x1 != 0, system: attrs & 0x4 != 0, created: created);
|
||||
} catch (_) {
|
||||
return null;
|
||||
} finally {
|
||||
calloc.free(name);
|
||||
calloc.free(info);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@ import 'package:path/path.dart' as p;
|
|||
|
||||
import '../l10n/tr.dart';
|
||||
import 'playlist.dart' show naturalCompare;
|
||||
import 'rsync_filter.dart';
|
||||
import 'vfs.dart';
|
||||
import 'webdav.dart' show DavException, DavLockedException;
|
||||
|
||||
|
|
@ -382,10 +381,6 @@ class FileOps {
|
|||
/// 건너뛴 항목 (원본 경로)
|
||||
final skipped = <String>[];
|
||||
|
||||
/// M1 (관리자 P0, 10/19): 원격 맞추기에서 원본의 파일과 같은 이름의 **폴더** 가 받는 쪽에 있어 건너뛴 것.
|
||||
/// --delete 가 없으면 그 폴더를 지우지 않는다 (rsync 도 --delete · --force 없이는 비어 있지 않은 폴더를 지우지 않음)
|
||||
final dirConflicts = <String>[];
|
||||
|
||||
FileOps({
|
||||
this.bandwidthKBps = 0,
|
||||
this.onFileDone,
|
||||
|
|
@ -632,12 +627,11 @@ class FileOps {
|
|||
///
|
||||
/// [update] (rsync -u): 대상이 더 새 파일이면 건너뛴다 (양쪽 ⇄ 을 함께 맞출 때 서로 덮어쓰지 않게).
|
||||
/// [strict] (214, 이동): 같음은 크기 + 바뀐 때 (±2초) 로만. 느슨하게 같아 보이는 것 (WebDAV 의 "대상이 더 새것") 은 다시 보낸다
|
||||
/// [filter] (관리자 지시 10/19, M2 의 본 고침): rsync 옵션의 거르기 - 원격 (앱이 직접 맞춤) 도 미리 보기와 같은 규칙을 따른다
|
||||
Future<int> mirror(String srcDir, String dstDir,
|
||||
{bool delete = false, bool update = false, bool allowEmptySource = false, bool strict = false, RsyncFilter filter = RsyncFilter.none}) async {
|
||||
{bool delete = false, bool update = false, bool allowEmptySource = false, bool strict = false}) async {
|
||||
// 지우기 포함이면 원본을 확실히 읽을 수 있을 때만 (읽지 못하면 원본이 빈 것으로 보여 대상이 모두 지워졌다)
|
||||
if (delete) await ensureSourceForDelete(srcDir, dstDir, allowEmpty: allowEmptySource);
|
||||
if (_anyDav([srcDir, dstDir])) return _mirrorV(srcDir, dstDir, delete: delete, update: update, strict: strict, filter: filter);
|
||||
if (_anyDav([srcDir, dstDir])) return _mirrorV(srcDir, dstDir, delete: delete, update: update, strict: strict);
|
||||
var n = 0;
|
||||
Future<void> walk(String src, String dst) async {
|
||||
_check();
|
||||
|
|
@ -734,7 +728,7 @@ class FileOps {
|
|||
final target = p.join(p.dirname(path), name);
|
||||
if (target == path) return path;
|
||||
if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound &&
|
||||
(target.toLowerCase() != path.toLowerCase() || _otherCaseExists(path, name))) {
|
||||
target.toLowerCase() != path.toLowerCase()) {
|
||||
throw FileSystemException(tr('같은 이름이 이미 있습니다'), target);
|
||||
}
|
||||
final t = FileSystemEntity.typeSync(path);
|
||||
|
|
@ -743,17 +737,6 @@ class FileOps {
|
|||
: (await File(path).rename(target)).path;
|
||||
}
|
||||
|
||||
/// 데이터 검토 (10/18): 대소문자만 바꾸는 이름 (a.jpg → A.jpg) 인데 대소문자를 가리는 저장소 (Android 의 앱 폴더 · ext4 · 일부 NAS) 에
|
||||
/// 그 이름의 다른 파일이 따로 있으면 true - rename 이 그 파일을 말없이 덮지 않게 (가리지 않는 곳이면 목록에 정확히 그 이름이 없다)
|
||||
static bool _otherCaseExists(String path, String name) {
|
||||
if (p.basename(path) == name) return false;
|
||||
try {
|
||||
return Directory(p.dirname(path)).listSync(followLinks: false).any((e) => p.basename(e.path) == name);
|
||||
} catch (_) {
|
||||
return true; // 모르면 막는다 (덮는 것보다 낫다)
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String> makeFolder(String dir, String name) async {
|
||||
final n = name.trim();
|
||||
final bad = nameProblem(n, windowsRules: usesWindowsNames(dir));
|
||||
|
|
@ -921,42 +904,9 @@ class FileOps {
|
|||
return empty;
|
||||
}
|
||||
|
||||
Future<int> _mirrorV(String srcDir, String dstDir,
|
||||
{bool delete = false, bool update = false, bool strict = false, RsyncFilter filter = RsyncFilter.none}) async {
|
||||
Future<int> _mirrorV(String srcDir, String dstDir, {bool delete = false, bool update = false, bool strict = false}) async {
|
||||
var n = 0;
|
||||
// 관리자 지시 (10/19): 거르기는 rsync 와 같게 (미리 보기 sync_preview 와 같은 규칙) - 거른 것은 보내지 않고 받는 쪽의 거른 것은 지우지 않는다
|
||||
// (--delete-excluded 면 지운다). 받는 쪽에만 있는 폴더 안에 거른 것이 있으면 폴더는 남기고 거르지 않은 것만 지운다
|
||||
Future<bool> hasExcluded(String dir, String rel) async {
|
||||
List<({String path, bool isDir, int size, DateTime modified})> items;
|
||||
try {
|
||||
items = await vList(dir);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
for (final x in items) {
|
||||
final r = '$rel/${vBasename(x.path)}';
|
||||
if (filter.protects(r, isDir: x.isDir)) return true;
|
||||
if (x.isDir && await hasExcluded(x.path, r)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> deleteInside(String dir, String rel) async {
|
||||
for (final x in await vList(dir)) {
|
||||
_check();
|
||||
final name = vBasename(x.path);
|
||||
if (name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
||||
final r = '$rel/$name';
|
||||
if (filter.protects(r, isDir: x.isDir)) continue;
|
||||
if (x.isDir && await hasExcluded(x.path, r)) {
|
||||
await deleteInside(x.path, r);
|
||||
} else {
|
||||
await vDelete(x.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> walk(String src, String dst, String rel) async {
|
||||
Future<void> walk(String src, String dst) async {
|
||||
_check();
|
||||
await vMkdirs(dst);
|
||||
final there = {for (final e in await vList(dst)) vBasename(e.path): e};
|
||||
|
|
@ -970,15 +920,12 @@ class FileOps {
|
|||
for (final e in items) {
|
||||
final name = vBasename(e.path);
|
||||
if (isAppTempName(name)) continue; // 78 보충
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
// 거른 것은 보내지 않는다 (거른 폴더 안으로도 내려가지 않음) - 받는 쪽의 같은 이름은 아래 지우기에서 (지키거나 --delete-excluded)
|
||||
if (filter.excluded(r, isDir: e.isDir)) continue;
|
||||
names.add(name);
|
||||
final target = vJoin(dst, name);
|
||||
// 214: 로컬 원본의 링크 · junction 은 따라가지 않는다 (로컬끼리 맞출 때처럼 - 링크가 가리키는 바깥 폴더를 건드리지 않게)
|
||||
if (!isDav(e.path) && FileSystemEntity.isLinkSync(e.path)) continue;
|
||||
if (e.isDir) {
|
||||
await walk(e.path, target, r);
|
||||
await walk(e.path, target);
|
||||
continue;
|
||||
}
|
||||
final t = there[name];
|
||||
|
|
@ -992,37 +939,19 @@ class FileOps {
|
|||
}
|
||||
}
|
||||
if (update && t != null && t.modified.isAfter(e.modified.add(const Duration(seconds: 2)))) continue;
|
||||
// M1 (관리자 P0, 10/19): 예전에는 --delete 가 없어도 같은 이름의 대상 폴더를 통째로 지우고 파일로 바꿨다 (미리 보기에는 "바뀜" 만).
|
||||
// 이제 --delete 일 때만 (미리 보기에 "폴더를 지우고 파일로"), 아니면 건너뛰고 알린다
|
||||
if (t != null && t.isDir) {
|
||||
if (!delete) {
|
||||
dirConflicts.add(target);
|
||||
continue;
|
||||
}
|
||||
await vDelete(target);
|
||||
}
|
||||
if (t != null && t.isDir) await vDelete(target);
|
||||
await _copyFileV(e.path, target, e.size, e.modified, null);
|
||||
n++;
|
||||
onSettled?.call(e.path, e.size, e.modified);
|
||||
}
|
||||
if (delete) {
|
||||
for (final t in there.entries) {
|
||||
final name = t.key;
|
||||
if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
||||
_check();
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
// 받는 쪽 규칙에 걸린 것만 지킨다 (관리자 지시 10/20: 보내는 쪽에서만 거른 H · -,s · --delete-excluded 의 그냥 규칙은 지움)
|
||||
if (filter.protects(r, isDir: t.value.isDir)) continue;
|
||||
if (t.value.isDir && filter.protectsAny && await hasExcluded(t.value.path, r)) {
|
||||
await deleteInside(t.value.path, r);
|
||||
continue;
|
||||
}
|
||||
await vDelete(t.value.path);
|
||||
for (final name in there.keys) {
|
||||
if (!names.contains(name) && !name.endsWith('.jjsync') && !isAppTempName(name)) await vDelete(vJoin(dst, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(srcDir, dstDir, '');
|
||||
await walk(srcDir, dstDir);
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,691 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart' show sha256;
|
||||
|
||||
import '../l10n/tr.dart';
|
||||
import 'dns_resolver.dart';
|
||||
import 'remote_fs.dart';
|
||||
import 'secret_gate.dart';
|
||||
import 'webdav.dart' show DavException, DavItem, DavLockedException;
|
||||
import 'remote_idle.dart';
|
||||
|
||||
/// FTP · FTPS 서버 설정 (관리자 지시 10/17, l). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
|
||||
/// [security]: 'explicit' (FTPS - AUTH TLS, 처음 값) · 'implicit' (FTPS 990) · 'none' (평문 FTP - 연결 · 설정에서 "암호화 안 됨" 경고).
|
||||
/// 자체 서명 인증서는 처음 연결 때 사용자가 믿은 지문 ([certFingerprint], SHA-256) 만 받는다 (TOFU - SFTP 호스트 키와 같은 판단).
|
||||
class FtpServer {
|
||||
const FtpServer({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.host,
|
||||
this.port = 21,
|
||||
this.user = '',
|
||||
this.root = '',
|
||||
this.password = '',
|
||||
this.security = 'explicit',
|
||||
this.certFingerprint = '',
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String host;
|
||||
final int port;
|
||||
final String user;
|
||||
|
||||
/// 서버의 시작 폴더 (비우면 로그인한 곳)
|
||||
final String root;
|
||||
|
||||
/// 안전 저장소에서 채움 (설정 파일에는 없음)
|
||||
final String password;
|
||||
final String security;
|
||||
|
||||
/// 믿은 자체 서명 인증서의 지문 "SHA256:xx:xx…" (비밀 아님)
|
||||
final String certFingerprint;
|
||||
|
||||
bool get tls => security != 'none';
|
||||
String get label => name.trim().isNotEmpty ? name.trim() : (host.trim().isEmpty ? 'FTP' : host.trim());
|
||||
|
||||
FtpServer copyWith({String? name, String? host, int? port, String? user, String? root, String? password, String? security, String? certFingerprint}) =>
|
||||
FtpServer(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
host: host ?? this.host,
|
||||
port: port ?? this.port,
|
||||
user: user ?? this.user,
|
||||
root: root ?? this.root,
|
||||
password: password ?? this.password,
|
||||
security: security ?? this.security,
|
||||
certFingerprint: certFingerprint ?? this.certFingerprint,
|
||||
);
|
||||
|
||||
/// 비밀 값은 넣지 않는다 (안전 저장소). 저장해 두었는지만
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'host': host,
|
||||
'port': port,
|
||||
'user': user,
|
||||
'root': root,
|
||||
'hasPassword': password.isNotEmpty,
|
||||
'security': security,
|
||||
'certFingerprint': certFingerprint,
|
||||
};
|
||||
|
||||
factory FtpServer.fromJson(Map<Object?, Object?> j) => FtpServer(
|
||||
id: '${j['id'] ?? DateTime.now().microsecondsSinceEpoch}',
|
||||
name: j['name'] as String? ?? '',
|
||||
host: j['host'] as String? ?? '',
|
||||
port: switch (j['port']) { final int p when p > 0 && p < 65536 => p, _ => j['security'] == 'implicit' ? 990 : 21 },
|
||||
user: j['user'] as String? ?? '',
|
||||
root: j['root'] as String? ?? '',
|
||||
// 모르는 값 · 없는 값은 FTPS (지침 3 - 덜 위험한 쪽, 관리자 지시 "처음 값은 FTPS 우선")
|
||||
security: const ['explicit', 'implicit', 'none'].contains(j['security']) ? j['security'] as String : 'explicit',
|
||||
certFingerprint: j['certFingerprint'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// FTP 실패의 종류 (안내 글 · 할 일 단추를 고르는 데)
|
||||
enum FtpFailure { unreachable, auth, noSecret, certUnknown, certChanged, noTls, permission, notFound, other }
|
||||
|
||||
class FtpException extends DavException {
|
||||
final FtpFailure kind;
|
||||
const FtpException(super.message, this.kind);
|
||||
}
|
||||
|
||||
/// 처음 보는 (또는 바뀐) 자체 서명 인증서: 화면이 지문을 보이고 믿을지 묻는다. 믿으면 [FtpServer.certFingerprint] 에 저장
|
||||
class FtpCertException extends FtpException {
|
||||
final String fingerprint;
|
||||
final String subject;
|
||||
|
||||
/// 바뀐 경우 예전에 믿은 지문
|
||||
final String? previous;
|
||||
const FtpCertException(super.message, super.kind, {required this.fingerprint, required this.subject, this.previous});
|
||||
bool get changed => previous != null;
|
||||
}
|
||||
|
||||
/// 인증서 지문 "SHA256:AB:CD:…"
|
||||
String certSha256(X509Certificate c) =>
|
||||
'SHA256:${sha256.convert(c.der).bytes.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(':')}';
|
||||
|
||||
/// 이름 → 연결할 주소 (DNS 강제면 정한 DNS - main 에서 바꾼다). 처음 값: 기기 DNS (주소를 그대로 넘김)
|
||||
typedef FtpResolve = Future<InternetAddress?> Function(String host);
|
||||
|
||||
/// FTP 명령 채널 하나 (로그인까지). 응답은 줄 단위 ("123-…" 여러 줄 → "123 …" 끝)
|
||||
class _Session {
|
||||
final FtpServer server;
|
||||
final Duration timeout;
|
||||
final InternetAddress addr;
|
||||
RawSocket _raw;
|
||||
StreamSubscription<RawSocketEvent>? _sub;
|
||||
final _buf = <int>[];
|
||||
final _lines = <String>[];
|
||||
Completer<void>? _wake;
|
||||
bool _closed = false;
|
||||
bool secure = false;
|
||||
bool mlsd = false;
|
||||
bool _protP = false;
|
||||
|
||||
/// 로그인 뒤 서버의 지금 폴더 (시작 폴더가 비었을 때 맨 위)
|
||||
String home = '/';
|
||||
|
||||
_Session._(this.server, this.timeout, this.addr, this._raw);
|
||||
|
||||
static Future<_Session> open(FtpServer s, Duration timeout, FtpResolve resolve) async {
|
||||
final addr = await resolve(s.host.trim()) ?? (await InternetAddress.lookup(s.host.trim())).first;
|
||||
final raw = await RawSocket.connect(addr, s.port, timeout: timeout);
|
||||
final x = _Session._(s, timeout, addr, raw);
|
||||
if (s.security == 'implicit') {
|
||||
x._raw = await x._secure(raw, null);
|
||||
x.secure = true;
|
||||
}
|
||||
x._listen();
|
||||
return x;
|
||||
}
|
||||
|
||||
void _listen() {
|
||||
_sub = _raw.listen((ev) {
|
||||
if (ev == RawSocketEvent.read) {
|
||||
final d = _raw.read();
|
||||
if (d != null) _feed(d);
|
||||
} else if (ev == RawSocketEvent.readClosed || ev == RawSocketEvent.closed) {
|
||||
_closed = true;
|
||||
_poke();
|
||||
}
|
||||
}, onError: (_) {
|
||||
_closed = true;
|
||||
_poke();
|
||||
});
|
||||
}
|
||||
|
||||
void _feed(Uint8List d) {
|
||||
_buf.addAll(d);
|
||||
while (true) {
|
||||
final i = _buf.indexOf(10);
|
||||
if (i < 0) break;
|
||||
var line = utf8.decode(_buf.sublist(0, i), allowMalformed: true);
|
||||
if (line.endsWith('\r')) line = line.substring(0, line.length - 1);
|
||||
_buf.removeRange(0, i + 1);
|
||||
debugLog?.call('< $line');
|
||||
_lines.add(line);
|
||||
}
|
||||
_poke();
|
||||
}
|
||||
|
||||
void _poke() {
|
||||
final w = _wake;
|
||||
_wake = null;
|
||||
w?.complete();
|
||||
}
|
||||
|
||||
Future<String> _line() async {
|
||||
final end = DateTime.now().add(timeout);
|
||||
while (_lines.isEmpty) {
|
||||
if (_closed) throw FtpException(trf('서버 ({0}) 와의 연결이 끊겼습니다', [server.host]), FtpFailure.unreachable);
|
||||
final left = end.difference(DateTime.now());
|
||||
if (left <= Duration.zero) throw TimeoutException(tr('답이 없습니다'));
|
||||
_wake = Completer<void>();
|
||||
await _wake!.future.timeout(left, onTimeout: () {});
|
||||
}
|
||||
return _lines.removeAt(0);
|
||||
}
|
||||
|
||||
/// 응답 하나 (여러 줄이면 합침): (코드, 글)
|
||||
Future<(int, String)> reply() async {
|
||||
final first = await _line();
|
||||
final code = int.tryParse(first.length >= 3 ? first.substring(0, 3) : '') ?? 0;
|
||||
final text = StringBuffer(first.length > 4 ? first.substring(4) : '');
|
||||
if (first.length > 3 && first[3] == '-') {
|
||||
while (true) {
|
||||
final l = await _line();
|
||||
if (l.startsWith('$code ')) {
|
||||
text.write('\n${l.substring(4)}');
|
||||
break;
|
||||
}
|
||||
text.write('\n${l.startsWith(' ') ? l.substring(1) : l}');
|
||||
}
|
||||
}
|
||||
return (code, text.toString());
|
||||
}
|
||||
|
||||
/// 시험: 명령 채널 주고받기 (비밀번호 줄은 가림)
|
||||
static void Function(String line)? debugLog;
|
||||
|
||||
void send(String cmd) {
|
||||
// 보안 검토 8: 경로 · 이름에 줄바꿈이 있으면 명령이 하나 더 끼어든다 - 보내지 않는다
|
||||
if (cmd.contains('\r') || cmd.contains('\n') || cmd.contains('\u0000')) {
|
||||
throw FtpException(tr('이름에 줄바꿈이 들어 있어 FTP 로 보낼 수 없습니다'), FtpFailure.other);
|
||||
}
|
||||
debugLog?.call('> ${cmd.startsWith('PASS ') ? 'PASS ***' : cmd}');
|
||||
_raw.write(utf8.encode('$cmd\r\n'));
|
||||
}
|
||||
|
||||
Future<(int, String)> cmd(String c) async {
|
||||
send(c);
|
||||
return reply();
|
||||
}
|
||||
|
||||
/// [c] 를 보내고 [ok] 코드 (2xx 등) 가 아니면 [FtpException]
|
||||
Future<String> must(String c, {bool Function(int code)? ok, String? path}) async {
|
||||
final (code, text) = await cmd(c);
|
||||
if (ok != null ? ok(code) : code >= 200 && code < 300) return text;
|
||||
throw failure(code, text, path);
|
||||
}
|
||||
|
||||
FtpException failure(int code, String text, String? path) {
|
||||
if (code == 530) return FtpException(tr('아이디나 비밀번호가 맞지 않습니다'), FtpFailure.auth);
|
||||
if (code == 550 || code == 450) {
|
||||
final t = text.toLowerCase();
|
||||
if (t.contains('permission') || t.contains('denied') || t.contains('access')) {
|
||||
return FtpException(trf('이 폴더 · 파일을 다룰 권한이 없습니다: {0}', [path ?? '']), FtpFailure.permission);
|
||||
}
|
||||
return FtpException(trf('없는 경로입니다: {0}', [path ?? '']), FtpFailure.notFound);
|
||||
}
|
||||
return FtpException('$code $text${path == null ? '' : ' ($path)'}', FtpFailure.other);
|
||||
}
|
||||
|
||||
/// TLS 로 바꾼다 (명시: AUTH TLS 뒤 · 암시: 연결하자마자). 자체 서명이면 믿은 지문만
|
||||
Future<RawSecureSocket> _secure(RawSocket raw, StreamSubscription<RawSocketEvent>? sub) async {
|
||||
X509Certificate? bad;
|
||||
try {
|
||||
return await RawSecureSocket.secure(raw, subscription: sub, host: server.host.trim(), onBadCertificate: (c) {
|
||||
bad = c;
|
||||
return server.certFingerprint.isNotEmpty && certSha256(c) == server.certFingerprint;
|
||||
}).timeout(timeout);
|
||||
} on HandshakeException {
|
||||
final c = bad;
|
||||
if (c == null) rethrow;
|
||||
final fp = certSha256(c);
|
||||
throw FtpCertException(
|
||||
server.certFingerprint.isEmpty
|
||||
? tr('처음 보는 서버 인증서입니다 (자체 서명). 지문을 확인한 뒤 믿을지 정하세요')
|
||||
: tr('서버 인증서가 바뀌었습니다. 다른 서버로 연결되었을 수 있습니다'),
|
||||
server.certFingerprint.isEmpty ? FtpFailure.certUnknown : FtpFailure.certChanged,
|
||||
fingerprint: fp,
|
||||
subject: c.subject,
|
||||
previous: server.certFingerprint.isEmpty ? null : server.certFingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
/// 인사 · TLS · 로그인 · UTF-8 · 이진 · 시작 폴더
|
||||
Future<void> login() async {
|
||||
final (hello, text) = await reply();
|
||||
if (hello != 220) throw failure(hello, text, null);
|
||||
if (server.security == 'explicit') {
|
||||
final (code, t) = await cmd('AUTH TLS');
|
||||
if (code != 234) {
|
||||
// FTPS 를 못 하는 서버: 몰래 평문으로 내려가지 않는다 (사용자가 "평문 FTP" 를 골라야)
|
||||
throw FtpException(trf('서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 "평문 FTP" 를 고르세요', [code, t]),
|
||||
FtpFailure.noTls);
|
||||
}
|
||||
// 보안 검토 2: 234 뒤에 평문으로 끼워 넣은 줄이 있으면 (중간자가 TLS 뒤의 답을 꾸미려는 것) 끊는다
|
||||
if (_lines.isNotEmpty || _buf.isNotEmpty) {
|
||||
throw FtpException(tr('서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다'), FtpFailure.noTls);
|
||||
}
|
||||
final sub = _sub;
|
||||
_sub = null;
|
||||
_raw = await _secure(_raw, sub);
|
||||
secure = true;
|
||||
// RawSecureSocket.secure 가 넘겨받은 구독을 이어 쓰지 않으므로 다시 듣는다
|
||||
_listen();
|
||||
}
|
||||
if (secure) {
|
||||
await must('PBSZ 0');
|
||||
await must('PROT P');
|
||||
_protP = true;
|
||||
}
|
||||
if (server.user.isNotEmpty || server.password.isNotEmpty) {
|
||||
final (code, t) = await cmd('USER ${server.user.isEmpty ? 'anonymous' : server.user}');
|
||||
if (code == 331 || code == 332) {
|
||||
await must('PASS ${server.password}', ok: (c) => c == 230 || c == 202);
|
||||
} else if (code != 230) {
|
||||
throw failure(code, t, null);
|
||||
}
|
||||
} else {
|
||||
await must('USER anonymous', ok: (c) => c == 230 || c == 331);
|
||||
}
|
||||
final feat = await cmd('FEAT');
|
||||
// MLST 를 알리면 MLSD 도 한다 (RFC 3659 - pyftpdlib 등은 FEAT 에 MLST 만 적는다)
|
||||
mlsd = feat.$1 == 211 && RegExp(r'^\s*MLS[TD]', multiLine: true).hasMatch(feat.$2.toUpperCase());
|
||||
if (feat.$2.toUpperCase().contains('UTF8')) await cmd('OPTS UTF8 ON');
|
||||
await must('TYPE I');
|
||||
final pwd = await cmd('PWD');
|
||||
final m = RegExp(r'"((?:[^"]|"")*)"').firstMatch(pwd.$2);
|
||||
if (pwd.$1 == 257 && m != null) home = m[1]!.replaceAll('""', '"');
|
||||
}
|
||||
|
||||
/// 자료 채널 (EPSV, 안 되면 PASV). 주소는 명령 채널의 서버 주소로 (서버가 알려 준 다른 주소로는 가지 않는다 - FTP 바운스 막기)
|
||||
Future<Socket> data() async {
|
||||
int? port;
|
||||
final (c1, t1) = await cmd('EPSV');
|
||||
if (c1 == 229) {
|
||||
final m = RegExp(r'\(\|\|\|(\d+)\|\)').firstMatch(t1);
|
||||
if (m != null) port = int.parse(m[1]!);
|
||||
}
|
||||
if (port == null) {
|
||||
final t = await must('PASV', ok: (c) => c == 227);
|
||||
final m = RegExp(r'(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)').firstMatch(t);
|
||||
if (m == null) throw FtpException(trf('서버의 자료 연결 응답을 읽지 못했습니다: {0}', [t]), FtpFailure.other);
|
||||
port = int.parse(m[5]!) * 256 + int.parse(m[6]!);
|
||||
}
|
||||
return Socket.connect(addr, port, timeout: timeout);
|
||||
}
|
||||
|
||||
/// 자료 연결을 TLS 로 (PROT P). 명령 (RETR · STOR · MLSD) 을 보내고 150/125 를 받은 뒤에 한다 - 서버는 명령을 받고서야 TLS 를 시작한다 (pyftpdlib 등)
|
||||
Future<Socket> secureData(Socket sock) async {
|
||||
if (!_protP) return sock;
|
||||
final x = await SecureSocket.secure(sock,
|
||||
host: server.host.trim(),
|
||||
onBadCertificate: (c) => server.certFingerprint.isNotEmpty && certSha256(c) == server.certFingerprint).timeout(timeout);
|
||||
return x;
|
||||
}
|
||||
|
||||
void close() {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
try {
|
||||
send('QUIT');
|
||||
} catch (_) {}
|
||||
_sub?.cancel();
|
||||
_raw.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// FTP · FTPS 클라이언트 (dart:io 로 직접 - 관리자 지시 l, 패키지 없이). 목록 · 정보 · 만들기 · 지우기 · 옮기기는 서버마다 명령 채널 하나를
|
||||
/// 열어 두고 차례로 쓰고, 받기 · 올리기는 따로 연결해 (동영상 건너뛰기처럼 여럿이 동시에 읽어도 서로 막지 않게) 끝나면 닫는다.
|
||||
class FtpRemote implements RemoteClient {
|
||||
final FtpServer server;
|
||||
final Duration timeout;
|
||||
FtpRemote(this.server, {this.timeout = const Duration(seconds: 15)});
|
||||
|
||||
Future<_Session>? _main;
|
||||
Future<void> _lock = Future.value();
|
||||
|
||||
Future<_Session> _connect() async {
|
||||
final s = server;
|
||||
if (s.host.trim().isEmpty) throw FtpException(tr('FTP 서버 주소가 없습니다'), FtpFailure.other);
|
||||
if (s.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage));
|
||||
if (s.user.isNotEmpty && s.password.isEmpty) {
|
||||
throw FtpException(tr('저장된 비밀번호가 없습니다. 서버 설정에서 비밀번호를 넣어 주세요'), FtpFailure.noSecret);
|
||||
}
|
||||
_Session? x;
|
||||
try {
|
||||
x = await _Session.open(s, timeout, FtpRegistry.resolve);
|
||||
await x.login();
|
||||
return x;
|
||||
} catch (e) {
|
||||
x?.close();
|
||||
throw _explain(e);
|
||||
}
|
||||
}
|
||||
|
||||
Object _explain(Object e) {
|
||||
if (e is FtpException || e is DavLockedException || e is RemoteIdleTimeout) return e;
|
||||
if (e is TimeoutException) return FtpException(trf('서버 ({0}) 가 답하지 않습니다', [server.host]), FtpFailure.unreachable);
|
||||
if (e is SocketException) return FtpException(trf('서버 ({0}) 에 연결할 수 없습니다: {1}', [server.host, e.message]), FtpFailure.unreachable);
|
||||
if (e is HandshakeException) return FtpException(trf('암호화 (TLS) 연결을 맺지 못했습니다: {0}', [e.message]), FtpFailure.other);
|
||||
return FtpException('$e', FtpFailure.other);
|
||||
}
|
||||
|
||||
/// 명령 채널을 차례로 쓴다 (한 번에 하나). 끊겼으면 한 번 다시 연결
|
||||
Future<T> _run<T>(Future<T> Function(_Session s) f) {
|
||||
final prev = _lock;
|
||||
final done = Completer<void>();
|
||||
_lock = done.future;
|
||||
return prev.then((_) async {
|
||||
try {
|
||||
for (var attempt = 0;; attempt++) {
|
||||
final _Session s;
|
||||
try {
|
||||
s = await (_main ??= _connect());
|
||||
} catch (_) {
|
||||
_main = null;
|
||||
rethrow;
|
||||
}
|
||||
try {
|
||||
return await f(s);
|
||||
} on FtpException catch (e) {
|
||||
if (e.kind == FtpFailure.unreachable && attempt == 0) {
|
||||
s.close();
|
||||
_main = null;
|
||||
continue;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is! FtpException && e is! DavLockedException) {
|
||||
_main = null;
|
||||
throw _explain(e);
|
||||
}
|
||||
rethrow;
|
||||
} finally {
|
||||
done.complete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 서버 기준 경로 ("/" 로 시작) → 서버의 절대 경로
|
||||
String _abs(_Session s, String rel) {
|
||||
var base = server.root.trim().isEmpty ? s.home : server.root.trim();
|
||||
if (!base.startsWith('/')) base = '/$base';
|
||||
if (base.length > 1 && base.endsWith('/')) base = base.substring(0, base.length - 1);
|
||||
final r = rel == '/' || rel.isEmpty ? '' : rel;
|
||||
if (base == '/') return r.isEmpty ? '/' : r;
|
||||
return '$base$r';
|
||||
}
|
||||
|
||||
static String _child(String rel, String name) => rel == '/' ? '/$name' : '$rel/$name';
|
||||
static String _parent(String rel) => rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/'));
|
||||
|
||||
/// MLSD · MLST 의 한 줄 "type=dir;size=1;modify=20261017120000; 이름"
|
||||
static (String, bool, int, DateTime)? parseMlsx(String line) {
|
||||
final sp = line.indexOf(' ');
|
||||
if (sp < 0) return null;
|
||||
final facts = <String, String>{};
|
||||
for (final f in line.substring(0, sp).split(';')) {
|
||||
final eq = f.indexOf('=');
|
||||
if (eq > 0) facts[f.substring(0, eq).toLowerCase()] = f.substring(eq + 1);
|
||||
}
|
||||
final name = line.substring(sp + 1);
|
||||
final type = (facts['type'] ?? '').toLowerCase();
|
||||
if (type == 'cdir' || type == 'pdir' || name == '.' || name == '..') return null;
|
||||
final m = facts['modify'] ?? '';
|
||||
var when = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
if (m.length >= 14) {
|
||||
when = DateTime.utc(int.parse(m.substring(0, 4)), int.parse(m.substring(4, 6)), int.parse(m.substring(6, 8)),
|
||||
int.parse(m.substring(8, 10)), int.parse(m.substring(10, 12)), int.parse(m.substring(12, 14)));
|
||||
}
|
||||
final isDir = type == 'dir';
|
||||
return (name, isDir, isDir ? 0 : int.tryParse(facts['size'] ?? '') ?? 0, when);
|
||||
}
|
||||
|
||||
static const _months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
||||
|
||||
/// LIST 의 유닉스 꼴 한 줄 "drwxr-xr-x 1 u g 0 Oct 17 12:00 이름" (MLSD 가 없는 서버)
|
||||
static (String, bool, int, DateTime)? parseList(String line) {
|
||||
final m = RegExp(r'^([dl-])[rwxsStT-]{9}\S*\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\w{3})\s+(\d{1,2})\s+(\d{1,2}:\d{2}|\d{4})\s(.+)$').firstMatch(line);
|
||||
if (m == null) return null;
|
||||
var name = m[6]!;
|
||||
if (m[1] == 'l' && name.contains(' -> ')) name = name.substring(0, name.indexOf(' -> '));
|
||||
if (name == '.' || name == '..') return null;
|
||||
final mon = _months.indexOf(m[3]!.toLowerCase()) + 1;
|
||||
final now = DateTime.now();
|
||||
final t = m[5]!;
|
||||
final when = t.contains(':')
|
||||
? DateTime(now.year, mon, int.parse(m[4]!), int.parse(t.split(':')[0]), int.parse(t.split(':')[1]))
|
||||
: DateTime(int.parse(t), mon, int.parse(m[4]!));
|
||||
final isDir = m[1] == 'd';
|
||||
return (name, isDir, isDir ? 0 : int.parse(m[2]!), when);
|
||||
}
|
||||
|
||||
/// 자료 채널로 목록 (MLSD 또는 LIST) 을 읽는다
|
||||
Future<List<(String, bool, int, DateTime)>> _listRaw(_Session s, String abs, String rel) async {
|
||||
final plain = await s.data();
|
||||
// LIST 에 -a 같은 옵션을 붙이면 경로로 읽는 서버가 있다
|
||||
s.send('${s.mlsd ? 'MLSD' : 'LIST'} $abs');
|
||||
final (code, text) = await s.reply();
|
||||
if (code != 150 && code != 125) {
|
||||
plain.destroy();
|
||||
throw s.failure(code, text, rel);
|
||||
}
|
||||
final sock = await s.secureData(plain);
|
||||
final bytes = await sock.fold<List<int>>(<int>[], (a, b) => a..addAll(b));
|
||||
final (end, et) = await s.reply();
|
||||
if (end != 226 && end != 250) throw s.failure(end, et, rel);
|
||||
final out = <(String, bool, int, DateTime)>[];
|
||||
for (final l in const LineSplitter().convert(utf8.decode(bytes, allowMalformed: true))) {
|
||||
final x = s.mlsd ? parseMlsx(l) : parseList(l);
|
||||
if (x != null) out.add(x);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<DavItem>> list(String rel) => _run((s) async => [
|
||||
for (final (n, dir, size, at) in await _listRaw(s, _abs(s, rel), rel)) DavItem(_child(rel, n), isDir: dir, size: size, modified: at),
|
||||
]);
|
||||
|
||||
@override
|
||||
Future<DavItem?> stat(String rel) async {
|
||||
if (rel == '/' || rel.isEmpty) return DavItem('/', isDir: true, modified: DateTime.fromMillisecondsSinceEpoch(0));
|
||||
// 위 폴더의 목록에서 찾는다 (MLST · SIZE 를 모든 서버가 같게 답하지 않아서 - 느리지만 확실하게)
|
||||
try {
|
||||
for (final x in await list(_parent(rel))) {
|
||||
if (x.rel == rel) return x;
|
||||
}
|
||||
} on FtpException catch (e) {
|
||||
if (e.kind == FtpFailure.notFound) return null;
|
||||
rethrow;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> mkdirs(String rel) async {
|
||||
if (rel == '/' || rel.isEmpty) return;
|
||||
if (await stat(rel) != null) return;
|
||||
await mkdirs(_parent(rel));
|
||||
await _run((s) => s.must('MKD ${_abs(s, rel)}', path: rel));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String rel) async {
|
||||
final st = await stat(rel);
|
||||
if (st == null) return;
|
||||
if (st.isDir) {
|
||||
for (final x in await list(rel)) {
|
||||
await delete(x.rel);
|
||||
}
|
||||
await _run((s) => s.must('RMD ${_abs(s, rel)}', path: rel));
|
||||
} else {
|
||||
await _run((s) => s.must('DELE ${_abs(s, rel)}', path: rel));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> move(String from, String to, {bool overwrite = false}) async {
|
||||
if (await stat(to) != null) {
|
||||
if (!overwrite) throw FtpException(trf('같은 이름이 이미 있습니다: {0}', [to]), FtpFailure.other);
|
||||
await delete(to);
|
||||
}
|
||||
await _run((s) async {
|
||||
await s.must('RNFR ${_abs(s, from)}', ok: (c) => c == 350, path: from);
|
||||
await s.must('RNTO ${_abs(s, to)}', path: to);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Stream<List<int>>> openRead(String rel) => openReadFrom(rel);
|
||||
|
||||
/// [offset] 부터 [length] 바이트 (null 이면 끝까지) - 따로 연결해 REST 로 (동영상 건너뛰기 · 이어 받기)
|
||||
Future<Stream<List<int>>> openReadFrom(String rel, {int offset = 0, int? length}) async {
|
||||
final s = await _connect();
|
||||
Socket? sock;
|
||||
try {
|
||||
sock = await s.data();
|
||||
if (offset > 0) await s.must('REST $offset', ok: (c) => c == 350, path: rel);
|
||||
s.send('RETR ${_abs(s, rel)}');
|
||||
final (code, text) = await s.reply();
|
||||
if (code != 150 && code != 125) throw s.failure(code, text, rel);
|
||||
sock = await s.secureData(sock);
|
||||
} catch (e) {
|
||||
sock?.destroy();
|
||||
s.close();
|
||||
throw _explain(e);
|
||||
}
|
||||
final data = sock;
|
||||
Stream<List<int>> read() async* {
|
||||
var left = length;
|
||||
try {
|
||||
// 관리자 판단 (10/20): 조각 사이에 정한 시간 동안 아무것도 오지 않으면 실패 (서버가 멈춰도 영원히 기다리지 않게)
|
||||
await for (var chunk in idleRead(data, onTimeout: data.destroy)) {
|
||||
if (left != null) {
|
||||
if (chunk.length >= left) {
|
||||
yield chunk.sublist(0, left);
|
||||
left = 0;
|
||||
break;
|
||||
}
|
||||
left -= chunk.length;
|
||||
}
|
||||
yield chunk;
|
||||
}
|
||||
if (left == null) {
|
||||
final (end, et) = await s.reply();
|
||||
if (end != 226 && end != 250) throw s.failure(end, et, rel);
|
||||
}
|
||||
} finally {
|
||||
data.destroy();
|
||||
s.close(); // 다 읽기 전에 멈추면 이 연결째 닫는다 (ABOR 대신 - 서버마다 답이 달라서)
|
||||
}
|
||||
}
|
||||
|
||||
return read();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(String rel, Stream<List<int>> data, {int? length}) async {
|
||||
final s = await _connect();
|
||||
try {
|
||||
final plain = await s.data();
|
||||
s.send('STOR ${_abs(s, rel)}');
|
||||
final (code, text) = await s.reply();
|
||||
if (code != 150 && code != 125) {
|
||||
plain.destroy();
|
||||
throw s.failure(code, text, rel);
|
||||
}
|
||||
final sock = await s.secureData(plain);
|
||||
// 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하면 실패 (연결을 끊음)
|
||||
await idleWrite(data, (x) async {
|
||||
await sock.addStream(x);
|
||||
await sock.flush();
|
||||
}, onTimeout: sock.destroy);
|
||||
// 닫기 (TLS 는 close_notify) 를 보내고, 서버가 답하지 않아도 오래 기다리지 않게 연결째 끊는다
|
||||
// 다 보낸 뒤 닫는다 (TLS 는 close_notify). close() 가 끝나도 암호화된 마지막 자료가 아직 나가지 않았을 수 있어
|
||||
// 연결이 정말 끝날 때 (done) 까지 기다리고, 서버가 답하지 않을 때만 끊는다 (곧바로 destroy 하면 빈 파일이 되었다)
|
||||
await sock.close().timeout(const Duration(seconds: 5), onTimeout: () => sock);
|
||||
await sock.done.timeout(const Duration(seconds: 5), onTimeout: () => sock.destroy()).catchError((_) {});
|
||||
final (end, et) = await s.reply();
|
||||
if (end != 226 && end != 250) throw s.failure(end, et, rel);
|
||||
} catch (e) {
|
||||
throw _explain(e);
|
||||
} finally {
|
||||
s.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// FTP 는 남은 공간을 알려 주는 표준 명령이 없다
|
||||
@override
|
||||
Future<(int?, int?)> quota() async => (null, null);
|
||||
|
||||
@override
|
||||
void close() {
|
||||
final m = _main;
|
||||
_main = null;
|
||||
m?.then((s) => s.close(), onError: (_) {});
|
||||
}
|
||||
}
|
||||
|
||||
/// 설정의 FTP 서버 목록과 클라이언트 (서버마다 하나)
|
||||
class FtpRegistry {
|
||||
static final _clients = <String, FtpRemote>{};
|
||||
static final _servers = <String, FtpServer>{};
|
||||
|
||||
/// 이름 풀기 (main 에서 DNS 강제를 따르게 바꾼다). 처음 값: 기기 DNS (null = InternetAddress.lookup)
|
||||
static FtpResolve resolve = (host) async => InternetAddress.tryParse(host);
|
||||
|
||||
/// 정한 DNS 로 이름을 풀기 (집 안 이름 · 주소는 기기 DNS) - SFTP 와 같게
|
||||
static FtpResolve resolveWithDns(DnsResolver resolver) => (host) async {
|
||||
final ip = InternetAddress.tryParse(host);
|
||||
if (ip != null) return ip;
|
||||
if (!resolver.config().forced || isLocalName(host)) return null;
|
||||
final addrs = await resolver.lookup(host);
|
||||
return addrs.where((a) => a.type == InternetAddressType.IPv4).firstOrNull ?? addrs.firstOrNull;
|
||||
};
|
||||
|
||||
static void configure(List<FtpServer> servers) {
|
||||
final keep = {for (final s in servers) s.id: s};
|
||||
for (final id in _clients.keys.toList()) {
|
||||
final old = _servers[id], now = keep[id];
|
||||
if (now == null || old == null || old.password != now.password || jsonEncode(old.toJson()) != jsonEncode(now.toJson())) {
|
||||
_clients.remove(id)?.close();
|
||||
}
|
||||
}
|
||||
_servers
|
||||
..clear()
|
||||
..addAll(keep);
|
||||
}
|
||||
|
||||
static List<FtpServer> get servers => _servers.values.toList();
|
||||
static FtpServer? server(String id) => _servers[id];
|
||||
|
||||
static FtpRemote client(String id) {
|
||||
final s = _servers[id];
|
||||
if (s == null) throw FtpException(trf('FTP 서버 설정이 없습니다 ({0})', [id]), FtpFailure.other);
|
||||
return _clients.putIfAbsent(id, () => FtpRemote(s));
|
||||
}
|
||||
}
|
||||
|
||||
/// 시험: 명령 채널 기록 (비밀번호 가림)
|
||||
void debugFtpLog(void Function(String line)? f) => _Session.debugLog = f;
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// X-plore 비교 j: 같은 망에서 SMB (445) 서버 찾기 - 이 기기의 IPv4 망 (/24) 을 짧은 연결 시도로 훑는다.
|
||||
/// 연결만 해 보고 바로 닫는다 (아무것도 보내지 않음). 찾은 것은 이름 (역방향 조회, 모르면 null) 과 함께.
|
||||
/// SMB 서버 추가 창 (88 Android SMB) 의 [찾기] 에서 쓴다.
|
||||
class LanHost {
|
||||
final String address;
|
||||
final String? name;
|
||||
const LanHost(this.address, this.name);
|
||||
}
|
||||
|
||||
/// 보안 검토 4: Wi-Fi · 유선 연결인지 (이름으로). 모바일 데이터 (통신사 망 - 다른 가입자) · 가상 어댑터 (WSL · Hyper-V ·
|
||||
/// VirtualBox · VMware · Docker) · VPN 은 아니다. Wi-Fi 공유 (89) 도 이것으로 고른다
|
||||
bool isLanInterfaceName(String name) {
|
||||
final n = name.toLowerCase();
|
||||
return !_mobileIf.hasMatch(n) && !_virtualIf.hasMatch(n);
|
||||
}
|
||||
|
||||
/// 모바일 데이터 (Android: rmnet · ccmni · pdp · v4-rmnet 등)
|
||||
bool isMobileInterfaceName(String name) => _mobileIf.hasMatch(name.toLowerCase());
|
||||
|
||||
final _mobileIf = RegExp(r'^(v4-)?(rmnet|ccmni|pdp|seth|wwan|usb_rmnet|rev_rmnet|clat)');
|
||||
final _virtualIf =
|
||||
RegExp(r'vethernet|hyper-v|virtualbox|vbox|vmware|vmnet|wsl|docker|^br-|^veth|virbr|^tun|^tap|^ppp|^ipsec|wireguard|^wg|zerotier|openvpn|tailscale');
|
||||
|
||||
/// 28 APK 점검 · 관리자 판단 (10/19): Android 의 망 종류 (ConnectivityManager · NetworkCapabilities) - 주소 → "wifi" · "ethernet" ·
|
||||
/// "cellular" · "vpn" · "other". 판단하지 못하면 (Android 가 아님 · 오류) null → 연결 이름으로 (예전 방식). 시험에서 바꾼다
|
||||
Future<Map<String, String>?> Function() netKindsOf = _androidNetKinds;
|
||||
|
||||
Future<Map<String, String>?> _androidNetKinds() async {
|
||||
if (!Platform.isAndroid) return null;
|
||||
try {
|
||||
final m = await const MethodChannel('jj_mkvmaker/android').invokeMethod<Map<Object?, Object?>>('netKinds');
|
||||
return m?.map((k, v) => MapEntry('$k', '$v'));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 집 안 망 (Wi-Fi · 유선) 의 주소인지: Android 가 알려 준 망 종류가 있으면 그것으로, 없으면 연결 이름으로
|
||||
bool isLanAddress(String ip, String interfaceName, Map<String, String>? kinds) {
|
||||
final k = kinds?[ip];
|
||||
if (k != null) return k == 'wifi' || k == 'ethernet';
|
||||
return isLanInterfaceName(interfaceName);
|
||||
}
|
||||
|
||||
/// 사설망 IPv4 (10/8 · 172.16/12 · 192.168/16) 인지
|
||||
bool isPrivateLanAddress(String ip) {
|
||||
final v = ip.split('.').map(int.tryParse).toList();
|
||||
if (v.length != 4 || v.any((x) => x == null || x < 0 || x > 255)) return false;
|
||||
return v[0] == 10 || (v[0] == 172 && v[1]! >= 16 && v[1]! <= 31) || (v[0] == 192 && v[1] == 168);
|
||||
}
|
||||
|
||||
/// 이 기기의 사설 IPv4 주소들 (Wi-Fi · 유선만 - Tailscale 100.64/10 은 넓어 훑지 않고, 모바일 데이터 · 가상 어댑터 · VPN 도 뺀다)
|
||||
Future<List<InternetAddress>> localPrivateIPv4() async {
|
||||
final out = <InternetAddress>[];
|
||||
final kinds = await netKindsOf();
|
||||
for (final ni in await NetworkInterface.list(type: InternetAddressType.IPv4)) {
|
||||
for (final a in ni.addresses) {
|
||||
if (!isLanAddress(a.address, ni.name, kinds)) continue;
|
||||
final b = a.rawAddress;
|
||||
final private = b[0] == 10 || (b[0] == 172 && b[1] >= 16 && b[1] <= 31) || (b[0] == 192 && b[1] == 168);
|
||||
if (private && !a.isLoopback) out.add(a);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// [hosts] 의 [port] 가 열려 있는지 동시에 [parallel] 개씩 본다. 열린 것만 (주소 순서대로)
|
||||
Future<List<LanHost>> scanPort(
|
||||
List<String> hosts, {
|
||||
int port = 445,
|
||||
Duration timeout = const Duration(milliseconds: 400),
|
||||
int parallel = 64,
|
||||
bool Function()? cancelled,
|
||||
void Function(int done, int total)? onProgress,
|
||||
bool lookupNames = true,
|
||||
}) async {
|
||||
final open = <String>{};
|
||||
var next = 0, done = 0;
|
||||
Future<void> worker() async {
|
||||
while (next < hosts.length) {
|
||||
if (cancelled?.call() ?? false) return;
|
||||
final h = hosts[next++];
|
||||
try {
|
||||
final s = await Socket.connect(h, port, timeout: timeout);
|
||||
open.add(h);
|
||||
s.destroy();
|
||||
} catch (_) {}
|
||||
onProgress?.call(++done, hosts.length);
|
||||
}
|
||||
}
|
||||
|
||||
await Future.wait([for (var i = 0; i < parallel; i++) worker()]);
|
||||
final sorted = hosts.where(open.contains).toList();
|
||||
final out = <LanHost>[];
|
||||
for (final h in sorted) {
|
||||
String? name;
|
||||
if (lookupNames) {
|
||||
try {
|
||||
final r = await InternetAddress(h).reverse().timeout(const Duration(seconds: 1));
|
||||
if (r.host != h) name = r.host;
|
||||
} catch (_) {}
|
||||
}
|
||||
out.add(LanHost(h, name));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// 이 기기의 사설망 (/24 마다) 의 SMB 서버들
|
||||
Future<List<LanHost>> findSmbServers({bool Function()? cancelled, void Function(int done, int total)? onProgress}) async {
|
||||
final hosts = <String>[];
|
||||
for (final a in await localPrivateIPv4()) {
|
||||
final b = a.rawAddress;
|
||||
for (var i = 1; i < 255; i++) {
|
||||
final h = '${b[0]}.${b[1]}.${b[2]}.$i';
|
||||
if (!hosts.contains(h)) hosts.add(h);
|
||||
}
|
||||
}
|
||||
return scanPort(hosts, cancelled: cancelled, onProgress: onProgress);
|
||||
}
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'lan_scan.dart' show LanHost, isPrivateLanAddress;
|
||||
|
||||
/// X-plore j 보충 (관리자 지시 10/18): mDNS 로 SMB 서버 찾기 (_smb._tcp.local) - 포트 훑기보다 먼저, 빠르게 (1~2초).
|
||||
/// "레거시 유니캐스트" 질의 (RFC 6762 6.7): 5353 이 아닌 빈 포트에서 224.0.0.251:5353 으로 묻고, 응답자가 그 포트로 바로 답한다 -
|
||||
/// Android 에서 멀티캐스트 받기 (MulticastLock) 없이도 받을 수 있다. 묻기만 하고 아무것도 알리지 않는다.
|
||||
/// 돌려주는 것: 서버 이름 (SRV 의 대상 · 없으면 인스턴스 이름) 과 IPv4 주소. 못 찾으면 빈 목록 (오류를 내지 않음)
|
||||
Future<List<LanHost>> findSmbServersMdns({Duration wait = const Duration(milliseconds: 1500), bool Function()? cancelled}) async {
|
||||
RawDatagramSocket? sock;
|
||||
try {
|
||||
sock = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0);
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
final id = Random().nextInt(0xffff);
|
||||
final ptr = <String>{}; // 인스턴스 이름들
|
||||
final srv = <String, String>{}; // 인스턴스 → 대상 호스트 이름
|
||||
final a = <String, String>{}; // 호스트 이름 → IPv4
|
||||
final from = <String, String>{}; // 인스턴스 → 응답을 보낸 주소 (A 가 없을 때)
|
||||
final done = Completer<void>();
|
||||
final sub = sock.listen((ev) {
|
||||
if (ev != RawSocketEvent.read) return;
|
||||
final d = sock!.receive();
|
||||
if (d == null) return;
|
||||
try {
|
||||
for (final r in parseMdns(d.data)) {
|
||||
switch (r.type) {
|
||||
case 12 when r.name.toLowerCase() == '_smb._tcp.local':
|
||||
ptr.add(r.data);
|
||||
from[r.data] = d.address.address;
|
||||
case 33:
|
||||
srv[r.name] = r.data;
|
||||
case 1:
|
||||
a[r.name.toLowerCase()] = r.data;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
sock.send(mdnsQuery(id, '_smb._tcp.local', 12), InternetAddress('224.0.0.251'), 5353);
|
||||
// 잠깐 기다리며 모은다 (취소되면 바로)
|
||||
final end = DateTime.now().add(wait);
|
||||
while (DateTime.now().isBefore(end) && !(cancelled?.call() ?? false)) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
if (!done.isCompleted) done.complete();
|
||||
await sub.cancel();
|
||||
sock.close();
|
||||
final out = <LanHost>[];
|
||||
final seen = <String>{};
|
||||
for (final inst in ptr) {
|
||||
final host = srv[inst];
|
||||
final ip = (host == null ? null : a[host.toLowerCase()]) ?? from[inst];
|
||||
// 보안 검토 11: 사설망 주소만 (가짜 응답이 바깥 주소를 이 망의 SMB 서버로 내세우지 않게)
|
||||
if (ip == null || !isPrivateLanAddress(ip) || !seen.add(ip)) continue;
|
||||
final label = host ?? inst.replaceFirst(RegExp(r'\._smb\._tcp\.local$', caseSensitive: false), '');
|
||||
out.add(LanHost(ip, label.endsWith('.') ? label.substring(0, label.length - 1) : label));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// mDNS 질의 하나 (QU 비트 - 유니캐스트로 답해 달라)
|
||||
Uint8List mdnsQuery(int id, String name, int type) {
|
||||
final b = BytesBuilder();
|
||||
b.add([id >> 8, id & 0xff, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0]);
|
||||
for (final part in name.split('.')) {
|
||||
final x = utf8.encode(part);
|
||||
b.addByte(x.length);
|
||||
b.add(x);
|
||||
}
|
||||
b.addByte(0);
|
||||
b.add([type >> 8, type & 0xff, 0x80, 0x01]); // 형식 · IN + QU
|
||||
return b.toBytes();
|
||||
}
|
||||
|
||||
/// 응답의 기록 (답 · 권한 · 추가 모두): (이름, 형식, 자료 - PTR/SRV 는 이름, A 는 주소)
|
||||
List<({String name, int type, String data})> parseMdns(Uint8List m) {
|
||||
final v = ByteData.sublistView(m);
|
||||
int u16(int o) => v.getUint16(o);
|
||||
String readName(int o, List<int> next) {
|
||||
final parts = <String>[];
|
||||
var jumped = false;
|
||||
var guard = 0;
|
||||
while (guard++ < 64) {
|
||||
final len = m[o];
|
||||
if (len == 0) {
|
||||
o += 1;
|
||||
break;
|
||||
}
|
||||
if (len & 0xc0 == 0xc0) {
|
||||
final ptr = ((len & 0x3f) << 8) | m[o + 1];
|
||||
if (!jumped) next.add(o + 2);
|
||||
jumped = true;
|
||||
o = ptr;
|
||||
continue;
|
||||
}
|
||||
parts.add(utf8.decode(m.sublist(o + 1, o + 1 + len), allowMalformed: true));
|
||||
o += 1 + len;
|
||||
}
|
||||
if (!jumped) next.add(o);
|
||||
return parts.join('.');
|
||||
}
|
||||
|
||||
final qd = u16(4), an = u16(6), ns = u16(8), ar = u16(10);
|
||||
var o = 12;
|
||||
for (var i = 0; i < qd; i++) {
|
||||
final n = <int>[];
|
||||
readName(o, n);
|
||||
o = n.first + 4;
|
||||
}
|
||||
final out = <({String name, int type, String data})>[];
|
||||
for (var i = 0; i < an + ns + ar; i++) {
|
||||
final n = <int>[];
|
||||
final name = readName(o, n);
|
||||
o = n.first;
|
||||
final type = u16(o), len = u16(o + 8);
|
||||
final rd = o + 10;
|
||||
String data = '';
|
||||
if (type == 12) {
|
||||
data = readName(rd, []);
|
||||
} else if (type == 33 && len > 6) {
|
||||
data = readName(rd + 6, []);
|
||||
} else if (type == 1 && len == 4) {
|
||||
data = '${m[rd]}.${m[rd + 1]}.${m[rd + 2]}.${m[rd + 3]}';
|
||||
}
|
||||
out.add((name: name, type: type, data: data));
|
||||
o = rd + len;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// 찾기 창의 기본: mDNS 를 먼저 (빠르게 · 이름까지), 이어서 포트 훑기 (mDNS 를 안 하는 NAS 도). 같은 주소는 하나로 (이름은 mDNS 것)
|
||||
Future<List<LanHost>> findSmbServersBoth(
|
||||
Future<List<LanHost>> Function({bool Function()? cancelled, void Function(int done, int total)? onProgress}) scan,
|
||||
{bool Function()? cancelled, void Function(int done, int total)? onProgress}) async {
|
||||
final m = await findSmbServersMdns(cancelled: cancelled);
|
||||
if (cancelled?.call() ?? false) return m;
|
||||
final s = await scan(cancelled: cancelled, onProgress: onProgress);
|
||||
final byIp = {for (final h in s) h.address: h};
|
||||
for (final h in m) {
|
||||
byIp[h.address] = h;
|
||||
}
|
||||
final out = byIp.values.toList()
|
||||
..sort((a, b) {
|
||||
int key(String ip) => ip.split('.').fold(0, (x, y) => x * 256 + (int.tryParse(y) ?? 0));
|
||||
return key(a.address).compareTo(key(b.address));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:image/image.dart' show decodeJpgExif;
|
||||
|
||||
/// X-plore 비교 i: 일괄 이름 바꾸기의 날짜 - 사진은 찍은 때 (JPEG Exif DateTimeOriginal), 없으면 null.
|
||||
/// 파일 앞부분만 읽는다 (Exif 는 맨 앞 APP1, 64KB 를 넘지 않음).
|
||||
Future<DateTime?> photoTakenAt(String path) async {
|
||||
final ext = path.toLowerCase();
|
||||
if (!(ext.endsWith('.jpg') || ext.endsWith('.jpeg'))) return null;
|
||||
RandomAccessFile? f;
|
||||
try {
|
||||
f = await File(path).open();
|
||||
final head = await f.read(256 * 1024);
|
||||
return parseExifDate(_exifDate(head));
|
||||
} catch (_) {
|
||||
return null;
|
||||
} finally {
|
||||
await f?.close();
|
||||
}
|
||||
}
|
||||
|
||||
String? _exifDate(Uint8List head) {
|
||||
try {
|
||||
final exif = decodeJpgExif(head);
|
||||
if (exif == null) return null;
|
||||
// 찍은 때 → 디지털로 만든 때 → 바뀐 때 (ifd0 의 DateTime)
|
||||
for (final v in [exif.exifIfd[0x9003], exif.exifIfd[0x9004], exif.imageIfd[0x0132]]) {
|
||||
final s = v?.toString().trim();
|
||||
if (s != null && s.isNotEmpty) return s;
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// "2024:05:01 12:34:56" → DateTime (틀리거나 0 이면 null)
|
||||
DateTime? parseExifDate(String? s) {
|
||||
final m = RegExp(r'^(\d{4})[:\-](\d{2})[:\-](\d{2})[ T](\d{2}):(\d{2}):(\d{2})').firstMatch(s ?? '');
|
||||
if (m == null) return null;
|
||||
final n = [for (var i = 1; i <= 6; i++) int.parse(m[i]!)];
|
||||
if (n[0] < 1900 || n[1] < 1 || n[1] > 12 || n[2] < 1 || n[2] > 31) return null;
|
||||
return DateTime(n[0], n[1], n[2], n[3], n[4], n[5]);
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import '../l10n/tr.dart';
|
||||
|
||||
/// 관리자 판단 (10/20): 원격 서버 (FTP · SFTP · SMB · WebDAV) 의 받기 · 올리기 자료 연결에서 조각 사이에 정한 시간 동안
|
||||
/// 아무것도 오가지 않으면 실패한다. 예전에는 서버가 중간에 멈추면 복사 · 동기화가 영원히 멈춰 있었다 (명령의 답에만 시간 제한).
|
||||
/// 정한 시간은 설정 "원격 서버 응답 기다리기" (처음 60초 · 30 · 60 · 120 · 300초) - 앱이 [limit] 에 넣는다
|
||||
class RemoteIdle {
|
||||
static const choices = [30, 60, 120, 300];
|
||||
static const defaultSeconds = 60;
|
||||
static Duration limit = const Duration(seconds: defaultSeconds);
|
||||
}
|
||||
|
||||
/// 조각 사이에 정한 시간이 지나도 서버가 아무것도 주고받지 않음
|
||||
class RemoteIdleTimeout implements Exception {
|
||||
final int seconds;
|
||||
const RemoteIdleTimeout(this.seconds);
|
||||
@override
|
||||
String toString() => trf('서버가 답하지 않습니다 ({0}초)', [seconds]);
|
||||
}
|
||||
|
||||
/// 받기: 조각 사이가 [limit] (없으면 [RemoteIdle.limit]) 를 넘으면 [RemoteIdleTimeout] 으로 끝나는 스트림.
|
||||
/// 그때 [onTimeout] 으로 그 연결을 끊고 원래 스트림의 구독도 끊는다 (늦게 오는 조각 · 오류를 받지 않음).
|
||||
/// 받는 쪽이 멈춰 (pause) 있는 동안은 세지 않는다
|
||||
Stream<T> idleRead<T>(Stream<T> s, {Duration? limit, void Function()? onTimeout}) {
|
||||
final d = limit ?? RemoteIdle.limit;
|
||||
late StreamController<T> out;
|
||||
StreamSubscription<T>? sub;
|
||||
Timer? t;
|
||||
void arm() {
|
||||
t?.cancel();
|
||||
t = Timer(d, () {
|
||||
final x = sub;
|
||||
sub = null;
|
||||
// 오류를 먼저 넣고 닫은 뒤에 연결을 끊는다 - 먼저 끊으면 원래 스트림의 "끝" 이 먼저 와서 정상으로 끝난 것처럼 보였다
|
||||
if (!out.isClosed) {
|
||||
out.addError(RemoteIdleTimeout(d.inSeconds));
|
||||
unawaited(out.close());
|
||||
}
|
||||
onTimeout?.call();
|
||||
unawaited(Future(() => x?.cancel()).catchError((_) {}));
|
||||
});
|
||||
}
|
||||
|
||||
out = StreamController<T>(
|
||||
onListen: () {
|
||||
arm();
|
||||
sub = s.listen((v) {
|
||||
if (out.isClosed) return;
|
||||
arm();
|
||||
out.add(v);
|
||||
}, onError: (Object e, StackTrace st) {
|
||||
if (out.isClosed) return;
|
||||
arm();
|
||||
out.addError(e, st);
|
||||
}, onDone: () {
|
||||
t?.cancel();
|
||||
if (!out.isClosed) unawaited(out.close());
|
||||
});
|
||||
},
|
||||
onPause: () {
|
||||
t?.cancel();
|
||||
sub?.pause();
|
||||
},
|
||||
onResume: () {
|
||||
sub?.resume();
|
||||
arm();
|
||||
},
|
||||
onCancel: () {
|
||||
t?.cancel();
|
||||
final x = sub;
|
||||
sub = null;
|
||||
return x?.cancel();
|
||||
},
|
||||
);
|
||||
return out.stream;
|
||||
}
|
||||
|
||||
/// 올리기: [send] 가 [data] 에서 [limit] 동안 한 조각도 더 가져가지 않으면 (서버가 받지 않아 보내기가 막힘) 또는 다 가져간 뒤
|
||||
/// [limit] 안에 끝나지 않으면 (서버의 마지막 답이 없음) [RemoteIdleTimeout]. 그때 [onTimeout] 으로 그 연결을 끊는다
|
||||
Future<T> idleWrite<T>(Stream<List<int>> data, Future<T> Function(Stream<List<int>> s) send,
|
||||
{Duration? limit, void Function()? onTimeout}) {
|
||||
final d = limit ?? RemoteIdle.limit;
|
||||
final fail = Completer<T>();
|
||||
Timer? t;
|
||||
void arm() {
|
||||
t?.cancel();
|
||||
t = Timer(d, () {
|
||||
if (fail.isCompleted) return;
|
||||
fail.completeError(RemoteIdleTimeout(d.inSeconds));
|
||||
onTimeout?.call();
|
||||
});
|
||||
}
|
||||
|
||||
arm();
|
||||
final work = send(data.map((x) {
|
||||
arm();
|
||||
return x;
|
||||
}));
|
||||
work.then((v) {
|
||||
t?.cancel();
|
||||
if (!fail.isCompleted) fail.complete(v);
|
||||
}, onError: (Object e, StackTrace st) {
|
||||
t?.cancel();
|
||||
if (!fail.isCompleted) fail.completeError(e, st);
|
||||
});
|
||||
return fail.future;
|
||||
}
|
||||
|
||||
/// 한 번의 요청 (SMB 조각 읽기 · 쓰기, WebDAV 응답 기다리기) 에 같은 시간 제한
|
||||
Future<T> idleCall<T>(Future<T> f, {Duration? limit, void Function()? onTimeout}) {
|
||||
final d = limit ?? RemoteIdle.limit;
|
||||
// 시간이 지나 연결을 끊은 뒤 원래 요청이 늦게 실패해도 (연결 닫힘 등) 처리되지 않은 오류로 남지 않게
|
||||
f.ignore();
|
||||
return f.timeout(d, onTimeout: () {
|
||||
onTimeout?.call();
|
||||
throw RemoteIdleTimeout(d.inSeconds);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'ftp.dart' show FtpRemote;
|
||||
import 'sftp.dart' show SftpRemote;
|
||||
import 'smb.dart' show SmbRemote;
|
||||
import 'vfs.dart';
|
||||
|
||||
/// sftp_next (관리자 지시 10/17): SFTP · SMB 동영상을 받지 않고 스트리밍하는 이 기기 안 (127.0.0.1) 의 작은 HTTP 다리.
|
||||
/// 플레이어 (mpv) 가 Range 로 부분을 달라고 하면 SFTP · SMB 에서 그 부분만 읽어 준다 - 건너뛰기가 되고,
|
||||
/// 같은 폴더의 다음 동영상으로 넘어갈 때도 미리 다 받지 않는다 (88 1차는 고른 파일 하나를 다 받아서 그것만 틀었다).
|
||||
/// 보안:
|
||||
/// - 127.0.0.1 에만 열고, 이 기기에서 온 요청만 받는다
|
||||
/// - 주소에 앱 실행마다 바뀌는 무작위 비밀값을 넣어 다른 앱이 쓰지 못하게 (DNS · Tor 다리와 같은 판단, a4 지적 10/11)
|
||||
/// - 등록한 파일만 (번호로) 열고, 경로를 주소로 받지 않는다. GET · HEAD 만
|
||||
class RemoteStreamBridge {
|
||||
static RemoteStreamBridge? _instance;
|
||||
|
||||
/// 하나만 (처음 쓸 때 연다)
|
||||
static Future<RemoteStreamBridge> get() async => _instance ??= await _start();
|
||||
|
||||
/// 시험: 닫고 다음에 새로 연다
|
||||
static Future<void> close() async {
|
||||
final b = _instance;
|
||||
_instance = null;
|
||||
await b?._server.close(force: true);
|
||||
}
|
||||
|
||||
final HttpServer _server;
|
||||
final String _secret;
|
||||
final _paths = <String>[];
|
||||
|
||||
RemoteStreamBridge._(this._server, this._secret);
|
||||
|
||||
static Future<RemoteStreamBridge> _start() async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final rng = Random.secure();
|
||||
final secret = base64Url.encode(List.generate(24, (_) => rng.nextInt(256))).replaceAll('=', '');
|
||||
final b = RemoteStreamBridge._(server, secret);
|
||||
server.autoCompress = false;
|
||||
server.listen(b._handle, onError: (_) {});
|
||||
return b;
|
||||
}
|
||||
|
||||
/// 플레이어에 줄 주소 (같은 경로는 같은 번호). 끝의 이름은 형식을 알아보라고 붙인다 (mpv 가 확장자를 본다)
|
||||
String url(String remote) {
|
||||
var i = _paths.indexOf(remote);
|
||||
if (i < 0) {
|
||||
_paths.add(remote);
|
||||
i = _paths.length - 1;
|
||||
}
|
||||
return 'http://127.0.0.1:${_server.port}/$_secret/$i/${Uri.encodeComponent(vBasename(remote))}';
|
||||
}
|
||||
|
||||
/// 원격 경로의 [start] 부터 [length] 바이트
|
||||
static Future<Stream<List<int>>> openRange(String remote, int start, int? length) async {
|
||||
final d = DavPath.parse(remote);
|
||||
final client = d.client;
|
||||
if (client is SftpRemote) return client.openReadFrom(d.rel, offset: start, length: length);
|
||||
if (client is SmbRemote) return client.openReadFrom(d.rel, start: start, length: length);
|
||||
// l FTP: REST 로 그 자리부터 (15 가 알린 것 - 합친 뒤 넣음)
|
||||
if (client is FtpRemote) return client.openReadFrom(d.rel, offset: start, length: length);
|
||||
// 그 밖 (WebDAV 는 다리를 쓰지 않지만 혹시 오면): 처음부터 읽으며 건너뛴다
|
||||
return _skip(await client.openRead(d.rel), start, length);
|
||||
}
|
||||
|
||||
static Stream<List<int>> _skip(Stream<List<int>> s, int start, int? length) async* {
|
||||
var skip = start;
|
||||
var left = length;
|
||||
await for (var chunk in s) {
|
||||
if (skip > 0) {
|
||||
if (chunk.length <= skip) {
|
||||
skip -= chunk.length;
|
||||
continue;
|
||||
}
|
||||
chunk = chunk.sublist(skip);
|
||||
skip = 0;
|
||||
}
|
||||
if (left != null) {
|
||||
if (left <= 0) break;
|
||||
if (chunk.length > left) chunk = chunk.sublist(0, left);
|
||||
left -= chunk.length;
|
||||
}
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handle(HttpRequest req) async {
|
||||
final res = req.response;
|
||||
try {
|
||||
final from = req.connectionInfo?.remoteAddress;
|
||||
final seg = req.uri.pathSegments;
|
||||
final i = seg.length >= 2 && seg[0] == _secret ? int.tryParse(seg[1]) : null;
|
||||
if (from == null || !from.isLoopback || i == null || i < 0 || i >= _paths.length) {
|
||||
res.statusCode = HttpStatus.notFound;
|
||||
await res.close();
|
||||
return;
|
||||
}
|
||||
if (req.method != 'GET' && req.method != 'HEAD') {
|
||||
res.statusCode = HttpStatus.methodNotAllowed;
|
||||
await res.close();
|
||||
return;
|
||||
}
|
||||
final remote = _paths[i];
|
||||
final st = await vStat(remote);
|
||||
if (st == null || st.isDir) {
|
||||
res.statusCode = HttpStatus.notFound;
|
||||
await res.close();
|
||||
return;
|
||||
}
|
||||
final len = st.size;
|
||||
var start = 0, end = len - 1, status = HttpStatus.ok;
|
||||
final m = RegExp(r'^bytes=(\d*)-(\d*)$').firstMatch((req.headers.value('range') ?? '').trim());
|
||||
if (m != null && len > 0) {
|
||||
final a = int.tryParse(m[1]!), b = int.tryParse(m[2]!);
|
||||
if (a == null && b != null) {
|
||||
start = max(0, len - b);
|
||||
} else if (a != null) {
|
||||
start = a;
|
||||
if (b != null) end = min(b, len - 1);
|
||||
}
|
||||
if (start >= len || start > end) {
|
||||
res.statusCode = HttpStatus.requestedRangeNotSatisfiable;
|
||||
res.headers.set('Content-Range', 'bytes */$len');
|
||||
await res.close();
|
||||
return;
|
||||
}
|
||||
status = HttpStatus.partialContent;
|
||||
res.headers.set('Content-Range', 'bytes $start-$end/$len');
|
||||
}
|
||||
res.statusCode = status;
|
||||
res.headers
|
||||
..set('Accept-Ranges', 'bytes')
|
||||
..set('Content-Type', _type(p.extension(remote).toLowerCase()));
|
||||
res.contentLength = len == 0 ? 0 : end - start + 1;
|
||||
if (req.method == 'HEAD' || len == 0) {
|
||||
await res.close();
|
||||
return;
|
||||
}
|
||||
await res.addStream(await openRange(remote, start, end - start + 1));
|
||||
await res.close();
|
||||
} catch (_) {
|
||||
// 플레이어가 건너뛰며 연결을 끊으면 여기로 온다 (괜찮음)
|
||||
try {
|
||||
res.statusCode = HttpStatus.badGateway;
|
||||
} catch (_) {}
|
||||
try {
|
||||
await res.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
static String _type(String ext) => switch (ext) {
|
||||
'.mp4' || '.m4v' => 'video/mp4',
|
||||
'.mkv' => 'video/x-matroska',
|
||||
'.webm' => 'video/webm',
|
||||
'.mov' => 'video/quicktime',
|
||||
'.avi' => 'video/x-msvideo',
|
||||
'.ts' || '.m2ts' => 'video/mp2t',
|
||||
'.mp3' => 'audio/mpeg',
|
||||
'.m4a' || '.aac' => 'audio/aac',
|
||||
'.flac' => 'audio/flac',
|
||||
'.ogg' || '.opus' => 'audio/ogg',
|
||||
'.wav' => 'audio/wav',
|
||||
_ => 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
|
|
@ -1,269 +0,0 @@
|
|||
import 'dart:convert' show utf8;
|
||||
import 'dart:io' show File, Platform;
|
||||
|
||||
import 'sync_tools.dart' show splitOptions;
|
||||
|
||||
/// 177: rsync 옵션의 거르기 규칙 (--exclude · --include · --exclude-from · --include-from · --filter "- …" / "+ …")
|
||||
/// 을 실행 전 비교 (미리 보기 · 지울 목록) 에도 똑같이 적용한다. 예전에는 미리 보기가 이 규칙을 보지 않아,
|
||||
/// 빼기로 한 폴더가 "새로" · "지워짐" 으로 나왔다 (실제 rsync 는 따름).
|
||||
///
|
||||
/// rsync 의 규칙 (man rsync "FILTER RULES") 그대로:
|
||||
/// - 위에서부터 처음 맞는 규칙이 이긴다. 맞는 것이 없으면 포함.
|
||||
/// - `*` = `/` 를 뺀 아무 글자, `**` = `/` 까지 아무 글자, `?` = `/` 가 아닌 한 글자, `[…]` = 글자 묶음.
|
||||
/// - 끝이 `/` 면 폴더에만 맞는다. 앞이 `/` 면 옮기는 맨 위 (원본/ 의 안) 기준.
|
||||
/// - `/` (끝 빼고) 나 `**` 가 있으면 경로 전체의 끝부분에, 없으면 이름에만 맞춘다.
|
||||
/// - `이름/***` 은 그 폴더와 안의 모든 것.
|
||||
/// - 거른 폴더 안으로는 내려가지 않는다. 받는 쪽의 거른 것은 지우지 않는다 (--delete-excluded 면 지운다).
|
||||
/// - rsync 처럼 바이트 단위: `?` · `[…]` 는 UTF-8 한 바이트 (그래서 `?.txt` 는 "가.txt" 에 맞지 않는다).
|
||||
/// - 관리자 지시 (10/20): 규칙마다 걸리는 쪽 - 그냥 `+` · `-` 는 양쪽, `H` (hide) = `-,s` · `S` (show) = `+,s` 는 보내는 쪽
|
||||
/// (보낼 것 고르기) 에만, `P` (protect) = `-,r` · `R` (risk) = `+,r` 은 받는 쪽 (지울 것 고르기) 에만. 쪽마다 그 쪽에 걸리는
|
||||
/// 규칙만 위에서부터 보고 처음 맞는 것이 이긴다. --delete-excluded 면 쪽을 적지 않은 규칙은 보내는 쪽에만 (rsync 와 같음).
|
||||
/// 보내는 쪽에서 거른 것 (보내지 않음) 은 받는 쪽에서 지울 것이 되고, 받는 쪽 규칙에 걸린 것만 지킨다.
|
||||
class RsyncFilter {
|
||||
final List<RsyncFilterRule> rules;
|
||||
|
||||
/// --delete-excluded: 받는 쪽의 거른 것도 지운다 (--delete 와 함께일 때)
|
||||
final bool deleteExcluded;
|
||||
|
||||
/// 미리 보기가 따르지 못하는 거르기 옵션 (-C · -F · merge 규칙 · 읽지 못한 파일) - 화면에서 알릴 것
|
||||
final List<String> unsupported;
|
||||
|
||||
const RsyncFilter(this.rules, {this.deleteExcluded = false, this.unsupported = const []});
|
||||
|
||||
static const none = RsyncFilter([]);
|
||||
|
||||
bool get isEmpty => rules.isEmpty && !deleteExcluded;
|
||||
|
||||
/// 받는 쪽에서 지키는 규칙이 하나라도 있는지 (없으면 받는 쪽에만 있는 것은 모두 지운다 - 폴더를 뒤질 필요 없음)
|
||||
bool get protectsAny => rules.any((r) => !r.include && _onReceiver(r));
|
||||
|
||||
bool _onSender(RsyncFilterRule r) => r.side != RuleSide.receiver;
|
||||
bool _onReceiver(RsyncFilterRule r) =>
|
||||
r.side == RuleSide.receiver || r.side == RuleSide.bothExplicit || (r.side == RuleSide.both && !deleteExcluded);
|
||||
|
||||
/// [options] (rsync 옵션 칸의 글) 에서 규칙을 읽는다. [readLines] 는 --exclude-from 파일 읽기 (시험에서 바꿈)
|
||||
factory RsyncFilter.parse(String options, {List<String>? Function(String path)? readLines}) {
|
||||
final read = readLines ?? _readLines;
|
||||
final t = splitOptions(options);
|
||||
final rules = <RsyncFilterRule>[];
|
||||
final unsupported = <String>[];
|
||||
var deleteExcluded = false;
|
||||
void fromFile(String path, bool include) {
|
||||
final lines = read(path);
|
||||
if (lines == null) {
|
||||
unsupported.add('${include ? '--include-from' : '--exclude-from'}=$path');
|
||||
return;
|
||||
}
|
||||
for (final l in lines) {
|
||||
final s = l.trimRight();
|
||||
if (s.isEmpty || s.startsWith('#') || s.startsWith(';')) continue;
|
||||
// 15 검토 F3: "!" (목록 지우기) · 다른 규칙 머리 (P · H · R · S · 고치개 · merge) 는 미리 볼 수 없다 - 적용하지 않고 알린다
|
||||
if (s == '!' || RegExp(r'^([PHRS]|[-+],[^ ]*|[.:]|merge|dir-merge)(\s|$)').hasMatch(s)) {
|
||||
unsupported.add('${include ? '--include-from' : '--exclude-from'}: $s');
|
||||
continue;
|
||||
}
|
||||
// 파일 안에서도 "+ " · "- " 머리를 따른다
|
||||
if (s.startsWith('+ ')) {
|
||||
rules.add(RsyncFilterRule(s.substring(2), include: true));
|
||||
} else if (s.startsWith('- ')) {
|
||||
rules.add(RsyncFilterRule(s.substring(2), include: false));
|
||||
} else {
|
||||
rules.add(RsyncFilterRule(s, include: include));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void filterRule(String r) {
|
||||
// 관리자 지시 (10/20): H · S · P · R 과 고치개 ",s" (보내는 쪽만) · ",r" (받는 쪽만) 를 rsync 와 같게 따른다
|
||||
// (예: "-,s a.txt" · "H a.txt" 는 보내지 않고, 받는 쪽 a.txt 는 지키지 않아 --delete 로 지워진다 - 미리 보기에도 그렇게).
|
||||
// 그 밖의 고치개 (",!" 반대 · ",p" · ",x" · ",e" 등) · merge · "!" 는 미리 볼 수 없다 - 적용하지 않고 알린다
|
||||
final m = RegExp(r'^(\+|-|include|exclude|H|S|P|R|hide|show|protect|risk)(?:,([a-zA-Z!/]*))?\s+(.+)$').firstMatch(r.trim());
|
||||
if (m == null) {
|
||||
unsupported.add('--filter=$r');
|
||||
return;
|
||||
}
|
||||
final name = m.group(1)!, mods = m.group(2) ?? '';
|
||||
final short = const {'include': '+', 'exclude': '-', 'hide': 'H', 'show': 'S', 'protect': 'P', 'risk': 'R'}[name] ?? name;
|
||||
// 쪽 (s · r) 말고 다른 고치개는 미리 볼 수 없다. H · S · P · R 에는 쪽 고치개도 받지 않는다 (이미 쪽이 정해짐)
|
||||
if (mods.contains(RegExp(r'[^sr]')) || ('HSPR'.contains(short) && mods.isNotEmpty)) {
|
||||
unsupported.add('--filter=$r');
|
||||
return;
|
||||
}
|
||||
final include = short == '+' || short == 'S' || short == 'R';
|
||||
final side = switch (short) {
|
||||
'H' || 'S' => RuleSide.sender,
|
||||
'P' || 'R' => RuleSide.receiver,
|
||||
_ => mods.contains('s') && !mods.contains('r')
|
||||
? RuleSide.sender
|
||||
: mods.contains('r') && !mods.contains('s')
|
||||
? RuleSide.receiver
|
||||
: mods.isEmpty
|
||||
? RuleSide.both
|
||||
: RuleSide.bothExplicit,
|
||||
};
|
||||
rules.add(RsyncFilterRule(m.group(3)!, include: include, side: side));
|
||||
}
|
||||
|
||||
for (var i = 0; i < t.length; i++) {
|
||||
final o = t[i];
|
||||
String? value(String name) {
|
||||
if (o.startsWith('$name=')) return o.substring(name.length + 1);
|
||||
if (o == name && i + 1 < t.length) return t[++i];
|
||||
return null;
|
||||
}
|
||||
|
||||
String? v;
|
||||
if ((v = value('--exclude')) != null) {
|
||||
rules.add(RsyncFilterRule(v!, include: false));
|
||||
} else if ((v = value('--include')) != null) {
|
||||
rules.add(RsyncFilterRule(v!, include: true));
|
||||
} else if ((v = value('--exclude-from')) != null) {
|
||||
fromFile(v!, false);
|
||||
} else if ((v = value('--include-from')) != null) {
|
||||
fromFile(v!, true);
|
||||
} else if ((v = value('--filter')) != null) {
|
||||
filterRule(v!);
|
||||
} else if (o == '--files-from' || o.startsWith('--files-from=')) {
|
||||
// 28 확인: 고른 파일만 보내므로 미리 보기와 다르다 (미리 보기는 더 많이 보임) - 알린다
|
||||
if (o == '--files-from' && i + 1 < t.length) i++;
|
||||
unsupported.add(o);
|
||||
} else if (o == '--delete-excluded') {
|
||||
deleteExcluded = true;
|
||||
} else if (o == '--cvs-exclude') {
|
||||
unsupported.add(o);
|
||||
} else if (o.length > 1 && o.startsWith('-') && !o.startsWith('--')) {
|
||||
// 짧은 옵션 묶음 (-avPog · -avf"- *.log" · -rf "- x"): rsync (popt) 처럼 한 글자씩 푼다 (28 확인).
|
||||
// 값을 받는 글자 (B · e · f · M · T · @) 를 만나면 그 뒤 글이 값, 비어 있으면 다음 낱말이 값.
|
||||
// 예전에는 -f 가 맨 앞일 때만 읽고, 묶음 안의 C · F 를 패턴 글자까지 뒤져 잘못 알렸다
|
||||
for (var j = 1; j < o.length; j++) {
|
||||
final ch = o[j];
|
||||
if ('BefMT@'.contains(ch)) {
|
||||
var arg = o.substring(j + 1);
|
||||
if (arg.isEmpty && i + 1 < t.length) arg = t[++i];
|
||||
if (ch == 'f') filterRule(arg);
|
||||
break;
|
||||
}
|
||||
// -C (CVS 빼기 목록) · -F (.rsync-filter 읽기) 는 미리 볼 수 없다
|
||||
if (ch == 'C' || ch == 'F') unsupported.add('-$ch');
|
||||
}
|
||||
}
|
||||
}
|
||||
return RsyncFilter(rules, deleteExcluded: deleteExcluded, unsupported: unsupported);
|
||||
}
|
||||
|
||||
static List<String>? _readLines(String path) {
|
||||
// Windows 의 rsync (MSYS2) 는 /cygdrive/c/… 로 적어야 읽는다 - 같은 파일을 여기서도 읽게
|
||||
final m = RegExp(r'^/cygdrive/([a-zA-Z])(/.*)?$').firstMatch(path);
|
||||
if (m != null && Platform.isWindows) path = '${m[1]!.toUpperCase()}:${m[2] ?? '/'}';
|
||||
try {
|
||||
return File(path).readAsLinesSync();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 옮기는 맨 위 기준 상대 경로 [rel] ("/" 구분) 이 보내는 쪽에서 걸러지는지 (보내지 않음 · 그 폴더 안으로 내려가지 않음)
|
||||
bool excluded(String rel, {required bool isDir}) {
|
||||
for (final r in rules) {
|
||||
if (_onSender(r) && r.matches(rel, isDir: isDir)) return !r.include;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 받는 쪽의 [rel] 을 지우기 (--delete) 에서 지키는지 - 받는 쪽에 걸리는 규칙 가운데 처음 맞는 것이 빼기 (- · P) 면 지킨다.
|
||||
/// 보내는 쪽에만 거른 것 (H · -,s · --delete-excluded 의 그냥 규칙) 은 지키지 않는다
|
||||
bool protects(String rel, {required bool isDir}) {
|
||||
for (final r in rules) {
|
||||
if (_onReceiver(r) && r.matches(rel, isDir: isDir)) return !r.include;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 규칙이 걸리는 쪽. [both] = 쪽을 적지 않은 규칙 (--delete-excluded 면 보내는 쪽만), [bothExplicit] = ",sr" 로 양쪽을 적음
|
||||
enum RuleSide { both, bothExplicit, sender, receiver }
|
||||
|
||||
class RsyncFilterRule {
|
||||
final String pattern;
|
||||
final bool include;
|
||||
final RuleSide side;
|
||||
late final bool _dirOnly;
|
||||
late final RegExp _re;
|
||||
|
||||
RsyncFilterRule(this.pattern, {required this.include, this.side = RuleSide.both}) {
|
||||
var p = pattern;
|
||||
var all = false; // "이름/***" = 그 폴더와 안의 모든 것
|
||||
if (p.endsWith('/***')) {
|
||||
all = true;
|
||||
p = p.substring(0, p.length - 4);
|
||||
_dirOnly = false;
|
||||
} else {
|
||||
_dirOnly = p.endsWith('/') && p.length > 1;
|
||||
if (_dirOnly) p = p.substring(0, p.length - 1);
|
||||
}
|
||||
final anchored = p.startsWith('/');
|
||||
if (anchored) p = p.substring(1);
|
||||
// "이름/***" 은 원래 패턴에 / 가 있으므로 경로로 맞춘다 (28 확인 10/20: 이름으로만 맞추면 받는 쪽에만 있는 keep/sub 같은
|
||||
// 안쪽 폴더를 "P keep/***" 가 지키지 못해 미리 보기가 더 지운다고 보였다 - 거른 폴더 안으로 내려가지 않는 보내는 쪽에서는 드러나지 않음)
|
||||
final whole = anchored || all || p.contains('/') || p.contains('**');
|
||||
// rsync 의 wildmatch 는 바이트로 맞춘다 - 패턴 · 이름을 UTF-8 바이트 (한 글자 = 한 바이트) 로 바꿔 맞춘다
|
||||
final body = _glob(_bytes(p)) + (all ? '(?:/.*)?' : '');
|
||||
_re = RegExp(anchored ? '^$body\$' : whole ? '^(?:.*/)?$body\$' : '^$body\$');
|
||||
_whole = whole;
|
||||
}
|
||||
|
||||
late final bool _whole;
|
||||
|
||||
bool matches(String rel, {required bool isDir}) {
|
||||
if (_dirOnly && !isDir) return false;
|
||||
if (_whole) return _re.hasMatch(_bytes(rel));
|
||||
final name = rel.contains('/') ? rel.substring(rel.lastIndexOf('/') + 1) : rel;
|
||||
return _re.hasMatch(_bytes(name));
|
||||
}
|
||||
|
||||
/// UTF-8 바이트를 한 글자씩 (0~255) 으로 - 정규식이 바이트 단위로 맞추게
|
||||
static String _bytes(String s) => String.fromCharCodes(utf8.encode(s));
|
||||
|
||||
/// rsync 의 wildmatch 를 정규식으로
|
||||
static String _glob(String p) {
|
||||
final b = StringBuffer();
|
||||
for (var i = 0; i < p.length; i++) {
|
||||
final c = p[i];
|
||||
if (c == '*') {
|
||||
if (i + 1 < p.length && p[i + 1] == '*') {
|
||||
i++;
|
||||
while (i + 1 < p.length && p[i + 1] == '*') {
|
||||
i++;
|
||||
}
|
||||
// rsync wildmatch: 패턴 맨 앞의 "**/" 는 폴더 0개에도 맞는다 ("**/deep/**" 가 맨 위 deep/x 에도).
|
||||
// 가운데의 "/**/" 는 폴더 하나 이상 ("foo/**/bar" 는 foo/bar 에 맞지 않음 - 실제 rsync 확인)
|
||||
if (b.isEmpty && i + 1 < p.length && p[i + 1] == '/') {
|
||||
b.write('(?:.*/)?');
|
||||
i++;
|
||||
} else {
|
||||
b.write('.*');
|
||||
}
|
||||
} else {
|
||||
b.write('[^/]*');
|
||||
}
|
||||
} else if (c == '?') {
|
||||
b.write('[^/]');
|
||||
} else if (c == '[') {
|
||||
final end = p.indexOf(']', i + 2);
|
||||
if (end < 0) {
|
||||
b.write(r'\[');
|
||||
continue;
|
||||
}
|
||||
var set = p.substring(i + 1, end);
|
||||
if (set.startsWith('!')) set = '^${set.substring(1)}';
|
||||
b.write('[${set.replaceAll(r'\', r'\\')}]');
|
||||
i = end;
|
||||
} else if (c == r'\' && i + 1 < p.length) {
|
||||
b.write(RegExp.escape(p[++i]));
|
||||
} else {
|
||||
b.write(RegExp.escape(c));
|
||||
}
|
||||
}
|
||||
return b.toString();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../l10n/tr.dart';
|
||||
|
||||
/// 관리자 지시 (10/20, 28 재현 - P0 데이터): Windows 의 rsync (MSYS2 · Cygwin 런타임) 는 Windows 프로그램이 넘긴 명령 줄을
|
||||
/// 스스로 다시 읽는다 - ' 를 따옴표로 짝지어 (--exclude=it's.txt --exclude=Bob's.txt 가 한 인수로 합쳐져 거르기가 사라지고
|
||||
/// 지킬 파일이 지워짐, ' 가 든 경로는 합쳐져 실패) * ? [ ] { } 를 작업 폴더의 파일로 펼친다 (--exclude *.log 가 x.log y.log 로).
|
||||
/// 앱이 만든 인수를 응답 파일 (@파일) 에 큰따옴표로 감싸 넣고 MSYS=noglob 으로 띄우면 그대로 간다 (실제 rsync 3.5.1 로 확인 -
|
||||
/// MSYS=noglob 만으로는 ' 를 막지 못하고, CYGWIN=noglob 은 소용없다).
|
||||
/// 응답 파일의 경로에 빈칸 · ' 가 있으면 @ 가 먹히지 않아, 그 파일이 든 폴더를 작업 폴더로 하고 "@args.txt" 로 넘긴다.
|
||||
/// 큰따옴표 (") 가 든 인수는 이 방법으로도 그대로 넘기지 못한다 - 실행하지 않고 이유를 알린다 (Windows 파일 이름에는 " 가 없다).
|
||||
class RsyncLaunch {
|
||||
final List<String> args;
|
||||
final String? workingDirectory;
|
||||
final Map<String, String>? environment;
|
||||
final Directory? _dir;
|
||||
RsyncLaunch._(this.args, this.workingDirectory, this.environment, this._dir);
|
||||
|
||||
/// 다 쓴 응답 파일을 지운다 (rsync 가 끝난 뒤)
|
||||
void dispose() {
|
||||
try {
|
||||
_dir?.deleteSync(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows rsync 에 그대로 넘길 수 없는 인수 (큰따옴표가 듦)
|
||||
List<String> msysUnsafeArgs(List<String> args) => [for (final a in args) if (a.contains('"')) a];
|
||||
|
||||
/// [args] 를 rsync 에 그대로 넘기도록 준비한다 (Windows 만 응답 파일 · MSYS=noglob, 그 밖은 그대로). 끝나면 [RsyncLaunch.dispose]
|
||||
Future<RsyncLaunch> prepareRsyncLaunch(List<String> args, {bool? windows}) async {
|
||||
if (!(windows ?? Platform.isWindows)) return RsyncLaunch._(args, null, null, null);
|
||||
final bad = msysUnsafeArgs(args);
|
||||
if (bad.isNotEmpty) {
|
||||
throw StateError(trf('큰따옴표 (") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}', [bad.join(' · ')]));
|
||||
}
|
||||
final dir = await Directory.systemTemp.createTemp('jj_rsargs_');
|
||||
await File(p.join(dir.path, 'args.txt')).writeAsString(args.map((a) => '"$a"').join(' '), encoding: utf8);
|
||||
final msys = Platform.environment['MSYS'];
|
||||
return RsyncLaunch._(['@args.txt'], dir.path, {'MSYS': msys == null || msys.trim().isEmpty ? 'noglob' : '$msys noglob'}, dir);
|
||||
}
|
||||
|
|
@ -10,7 +10,6 @@ import 'dns_resolver.dart';
|
|||
import 'remote_fs.dart';
|
||||
import 'secret_gate.dart';
|
||||
import 'webdav.dart' show DavException, DavItem, DavLockedException;
|
||||
import 'remote_idle.dart';
|
||||
|
||||
/// 88: SFTP 서버 설정 (환경 설정 > 파일 탐색기 > 네트워크 서버). 비밀번호 · 개인 키 · 키 암호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
|
||||
/// 호스트 키 (지문) 는 비밀이 아니라 설정에 둔다 - 처음 연결 때 사용자가 믿은 것 (TOFU).
|
||||
|
|
@ -267,13 +266,8 @@ class SftpRemote implements RemoteClient {
|
|||
return x;
|
||||
}
|
||||
|
||||
/// 관리자 판단 (10/20): SFTP 명령 하나 (목록 · 열기 · 닫기 · 지우기 등) 를 정한 시간 ([RemoteIdle]) 까지만 기다린다 -
|
||||
/// 서버가 멈추면 이 연결을 닫고 (다음에 다시 연다) "서버가 답하지 않습니다"
|
||||
Future<T> _cmd<T>(Future<T> f) => idleCall(f, onTimeout: close);
|
||||
|
||||
/// 파일 다루다 난 오류 (SFTP 상태) → 사람 말. 끊긴 연결은 다음에 다시 연다
|
||||
Never _fail(Object e, String rel) {
|
||||
if (e is RemoteIdleTimeout) throw e;
|
||||
if (e is SftpStatusError) {
|
||||
switch (e.code) {
|
||||
case SftpStatusCode.noSuchFile:
|
||||
|
|
@ -304,13 +298,13 @@ class SftpRemote implements RemoteClient {
|
|||
final c = await _open();
|
||||
try {
|
||||
final out = <DavItem>[];
|
||||
for (final n in await _cmd(c.sftp.listdir(_remote(c, rel)))) {
|
||||
for (final n in await c.sftp.listdir(_remote(c, rel))) {
|
||||
if (n.filename == '.' || n.filename == '..') continue;
|
||||
var a = n.attr;
|
||||
// 링크는 가리키는 것으로 (폴더 링크를 폴더로) - 따라갈 수 없으면 크기 0 파일로
|
||||
if (a.type == SftpFileType.symbolicLink) {
|
||||
try {
|
||||
a = await _cmd(c.sftp.stat(_remote(c, _child(rel, n.filename))));
|
||||
a = await c.sftp.stat(_remote(c, _child(rel, n.filename)));
|
||||
} catch (_) {}
|
||||
}
|
||||
out.add(_item(_child(rel, n.filename), a));
|
||||
|
|
@ -325,7 +319,7 @@ class SftpRemote implements RemoteClient {
|
|||
Future<DavItem?> stat(String rel) async {
|
||||
final c = await _open();
|
||||
try {
|
||||
return _item(rel, await _cmd(c.sftp.stat(_remote(c, rel))));
|
||||
return _item(rel, await c.sftp.stat(_remote(c, rel)));
|
||||
} on SftpStatusError catch (e) {
|
||||
if (e.code == SftpStatusCode.noSuchFile) return null;
|
||||
_fail(e, rel);
|
||||
|
|
@ -345,7 +339,7 @@ class SftpRemote implements RemoteClient {
|
|||
await mkdirs(_parent(rel));
|
||||
final c = await _open();
|
||||
try {
|
||||
await _cmd(c.sftp.mkdir(_remote(c, rel)));
|
||||
await c.sftp.mkdir(_remote(c, rel));
|
||||
} catch (e) {
|
||||
// 그 사이에 다른 곳이 만들었으면 그대로
|
||||
if ((await stat(rel))?.isDir == true) return;
|
||||
|
|
@ -363,9 +357,9 @@ class SftpRemote implements RemoteClient {
|
|||
for (final x in await list(rel)) {
|
||||
await delete(x.rel);
|
||||
}
|
||||
await _cmd(c.sftp.rmdir(_remote(c, rel)));
|
||||
await c.sftp.rmdir(_remote(c, rel));
|
||||
} else {
|
||||
await _cmd(c.sftp.remove(_remote(c, rel)));
|
||||
await c.sftp.remove(_remote(c, rel));
|
||||
}
|
||||
} catch (e) {
|
||||
_fail(e, rel);
|
||||
|
|
@ -380,28 +374,25 @@ class SftpRemote implements RemoteClient {
|
|||
final t = await stat(to);
|
||||
if (t != null && !t.isDir) {
|
||||
try {
|
||||
await _cmd(c.sftp.remove(_remote(c, to)));
|
||||
await c.sftp.remove(_remote(c, to));
|
||||
} catch (e) {
|
||||
_fail(e, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await _cmd(c.sftp.rename(_remote(c, from), _remote(c, to)));
|
||||
await c.sftp.rename(_remote(c, from), _remote(c, to));
|
||||
} catch (e) {
|
||||
_fail(e, from);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Stream<List<int>>> openRead(String rel) => openReadFrom(rel);
|
||||
|
||||
/// [offset] 부터 [length] 바이트 (null 이면 끝까지) 만 읽는다 - 동영상 스트리밍 다리의 Range (sftp_next)
|
||||
Future<Stream<List<int>>> openReadFrom(String rel, {int offset = 0, int? length}) async {
|
||||
Future<Stream<List<int>>> openRead(String rel) async {
|
||||
final c = await _open();
|
||||
final SftpFile f;
|
||||
try {
|
||||
f = await _cmd(c.sftp.open(_remote(c, rel)));
|
||||
f = await c.sftp.open(_remote(c, rel));
|
||||
} catch (e) {
|
||||
_fail(e, rel);
|
||||
}
|
||||
|
|
@ -409,15 +400,14 @@ class SftpRemote implements RemoteClient {
|
|||
StreamSubscription<Uint8List>? sub;
|
||||
out = StreamController<List<int>>(
|
||||
onListen: () {
|
||||
// 관리자 판단 (10/20): 조각 사이에 정한 시간 동안 아무것도 오지 않으면 실패 - 그 연결은 닫고 다음에 다시 연다
|
||||
sub = idleRead(f.read(offset: offset, length: length), onTimeout: close).listen(out.add, onError: (Object e) {
|
||||
sub = f.read().listen(out.add, onError: (Object e) {
|
||||
try {
|
||||
_fail(e, rel);
|
||||
} catch (x) {
|
||||
out.addError(x);
|
||||
}
|
||||
}, onDone: () async {
|
||||
await _cmd(f.close()).catchError((_) {});
|
||||
await f.close().catchError((_) {});
|
||||
await out.close();
|
||||
});
|
||||
},
|
||||
|
|
@ -425,7 +415,7 @@ class SftpRemote implements RemoteClient {
|
|||
onResume: () => sub?.resume(),
|
||||
onCancel: () async {
|
||||
await sub?.cancel();
|
||||
await _cmd(f.close()).catchError((_) {});
|
||||
await f.close().catchError((_) {});
|
||||
},
|
||||
);
|
||||
return out.stream;
|
||||
|
|
@ -436,18 +426,17 @@ class SftpRemote implements RemoteClient {
|
|||
final c = await _open();
|
||||
final SftpFile f;
|
||||
try {
|
||||
f = await _cmd(c.sftp.open(_remote(c, rel),
|
||||
mode: SftpFileOpenMode.create | SftpFileOpenMode.truncate | SftpFileOpenMode.write));
|
||||
f = await c.sftp.open(_remote(c, rel),
|
||||
mode: SftpFileOpenMode.create | SftpFileOpenMode.truncate | SftpFileOpenMode.write);
|
||||
} catch (e) {
|
||||
_fail(e, rel);
|
||||
}
|
||||
try {
|
||||
// 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하면 실패 (그 연결을 닫음)
|
||||
await idleWrite(data, (x) => f.write(x.map((y) => y is Uint8List ? y : Uint8List.fromList(y))).done, onTimeout: close);
|
||||
await f.write(data.map((x) => x is Uint8List ? x : Uint8List.fromList(x))).done;
|
||||
} catch (e) {
|
||||
_fail(e, rel);
|
||||
} finally {
|
||||
await _cmd(f.close()).catchError((_) {});
|
||||
await f.close().catchError((_) {});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -455,7 +444,7 @@ class SftpRemote implements RemoteClient {
|
|||
Future<(int?, int?)> quota() async {
|
||||
final c = await _open();
|
||||
try {
|
||||
final v = await _cmd(c.sftp.statvfs(c.root));
|
||||
final v = await c.sftp.statvfs(c.root);
|
||||
final unit = v.fundamentalBlockSize > 0 ? v.fundamentalBlockSize : v.blockSize;
|
||||
return (v.freeBlocksForNonRoot * unit, (v.totalBlocks - v.freeBlocks) * unit);
|
||||
} catch (_) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show InternetAddress;
|
||||
import 'dart:math' show min;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
|
@ -10,7 +8,6 @@ import '../l10n/tr.dart';
|
|||
import 'remote_fs.dart';
|
||||
import 'secret_gate.dart';
|
||||
import 'webdav.dart' show DavException, DavItem, DavLockedException;
|
||||
import 'remote_idle.dart';
|
||||
|
||||
/// 88: SMB 서버 설정 (Android 만 - Windows 는 운영체제가 \\서버\공유 를 연다, 사용자 결정 88-1).
|
||||
/// 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
|
||||
|
|
@ -84,7 +81,7 @@ class SmbServer {
|
|||
);
|
||||
}
|
||||
|
||||
enum SmbFailure { unreachable, auth, noPassword, noShare, smb1, permission, notFound, insecure, other }
|
||||
enum SmbFailure { unreachable, auth, noPassword, noShare, smb1, permission, notFound, other }
|
||||
|
||||
/// SMB 오류 - WebDAV 오류와 같은 곳에서 보이도록 [DavException] 을 잇는다
|
||||
class SmbException extends DavException {
|
||||
|
|
@ -100,10 +97,6 @@ class SmbRemote implements RemoteClient {
|
|||
|
||||
/// 시험에서 가짜로 바꾼다
|
||||
static MethodChannel channel = const MethodChannel('jj_mkvmaker/smb');
|
||||
|
||||
/// 보안 검토 5: 서버 이름을 앱의 DNS 설정 (강제 DNS) 으로 푼다 - null 이면 그대로 넘겨 기기가 푼다 (주소 · 집 안 이름).
|
||||
/// main 에서 FTP 와 같은 것으로 바꾼다
|
||||
static Future<InternetAddress?> Function(String host) resolve = (host) async => null;
|
||||
static const chunk = 1 << 20;
|
||||
|
||||
Map<String, Object?> get _cfg => {
|
||||
|
|
@ -114,8 +107,6 @@ class SmbRemote implements RemoteClient {
|
|||
'user': server.user.trim(),
|
||||
'domain': server.domain.trim(),
|
||||
'password': server.password,
|
||||
// 보안 검토 6: 서명 · 암호화가 안 되는 연결은 막기 (설정)
|
||||
'requireSecure': SmbRegistry.requireSecure,
|
||||
};
|
||||
|
||||
/// 서버 기준 경로 → 공유 안의 경로 (시작 폴더 포함)
|
||||
|
|
@ -131,24 +122,8 @@ class SmbRemote implements RemoteClient {
|
|||
}
|
||||
if (server.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage));
|
||||
if (server.user.isNotEmpty && server.password.isEmpty) throw SmbException(tr('저장된 비밀번호가 없습니다'), SmbFailure.noPassword);
|
||||
// 보안 검토 5: 이름은 여기서 풀어 주소로 넘긴다 (Android 의 SMB 다리가 시스템 DNS 로 풀어 강제 DNS 를 비켜 가지 않게)
|
||||
String host;
|
||||
try {
|
||||
host = (await resolve(server.host.trim()))?.address ?? server.host.trim();
|
||||
} catch (e) {
|
||||
throw SmbException(trf('서버 주소를 찾지 못했습니다 (DNS): {0}', [e]), SmbFailure.unreachable);
|
||||
}
|
||||
try {
|
||||
final out = await channel.invokeMethod<T>(method, {..._cfg, 'host': host, ...args});
|
||||
// 보안 검토 6: 처음 연결이 되면 서명 · 암호화되는지 한 번 물어 둔다 (탐색기 탭의 ⚠)
|
||||
if (!_checked && method != 'info') {
|
||||
_checked = true;
|
||||
try {
|
||||
final m = await channel.invokeMethod<Map<Object?, Object?>>('info', {..._cfg, 'host': host});
|
||||
SmbRegistry._secure[server.id] = m?['encrypted'] == true || m?['signed'] == true;
|
||||
} catch (_) {}
|
||||
}
|
||||
return out;
|
||||
return await channel.invokeMethod<T>(method, {..._cfg, ...args});
|
||||
} on PlatformException catch (e) {
|
||||
throw _explain(e, rel);
|
||||
} on MissingPluginException {
|
||||
|
|
@ -156,14 +131,9 @@ class SmbRemote implements RemoteClient {
|
|||
}
|
||||
}
|
||||
|
||||
bool _checked = false;
|
||||
|
||||
SmbException _explain(PlatformException e, String rel) {
|
||||
final s = server;
|
||||
return switch (e.code) {
|
||||
'insecure' => SmbException(
|
||||
tr('서명 · 암호화가 되지 않는 연결이라 열지 않았습니다 (설정 "SMB 서명 · 암호화 필수"). NAS 에서 SMB 서명이나 SMB3 암호화를 켜거나, 설정을 끄세요'),
|
||||
SmbFailure.insecure),
|
||||
'unreachable' => SmbException(
|
||||
trf('서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요', [s.host]), SmbFailure.unreachable),
|
||||
'auth' => SmbException(tr('아이디나 비밀번호가 맞지 않습니다'), SmbFailure.auth),
|
||||
|
|
@ -185,12 +155,10 @@ class SmbRemote implements RemoteClient {
|
|||
size: m['dir'] == true ? 0 : (m['size'] as num?)?.toInt() ?? 0,
|
||||
modified: DateTime.fromMillisecondsSinceEpoch((m['mtime'] as num?)?.toInt() ?? 0));
|
||||
|
||||
/// 연결이 암호화 · 서명되는지 · 어떤 SMB 판인지 (88-4: 암호화되지 않으면 화면이 경고)
|
||||
Future<({bool encrypted, bool signed, String dialect})> info() async {
|
||||
/// 연결이 암호화되는지 · 어떤 SMB 판인지 (88-4: 암호화되지 않으면 화면이 경고)
|
||||
Future<({bool encrypted, String dialect})> info() async {
|
||||
final m = await _call<Map<Object?, Object?>>('info', const {}, '/');
|
||||
final encrypted = m?['encrypted'] == true, signed = m?['signed'] == true;
|
||||
SmbRegistry._secure[server.id] = encrypted || signed;
|
||||
return (encrypted: encrypted, signed: signed, dialect: '${m?['dialect'] ?? ''}');
|
||||
return (encrypted: m?['encrypted'] == true, dialect: '${m?['dialect'] ?? ''}');
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -233,28 +201,21 @@ class SmbRemote implements RemoteClient {
|
|||
_call<void>('move', {'path': _path(from), 'to': _path(to), 'overwrite': overwrite}, from);
|
||||
|
||||
@override
|
||||
Future<Stream<List<int>>> openRead(String rel) => openReadFrom(rel);
|
||||
|
||||
/// [start] 부터 [length] 바이트 (null 이면 끝까지) 만 읽는다 - 동영상 스트리밍 다리의 Range (sftp_next)
|
||||
Future<Stream<List<int>>> openReadFrom(String rel, {int start = 0, int? length}) async {
|
||||
Future<Stream<List<int>>> openRead(String rel) async {
|
||||
final m = await _call<Map<Object?, Object?>>('openRead', {'path': _path(rel)}, rel);
|
||||
final handle = m!['handle'] as int;
|
||||
Stream<List<int>> read() async* {
|
||||
var offset = start;
|
||||
var left = length;
|
||||
var offset = 0;
|
||||
try {
|
||||
while (left == null || left > 0) {
|
||||
while (true) {
|
||||
final Uint8List? data;
|
||||
try {
|
||||
// 관리자 판단 (10/20): 한 조각을 정한 시간 안에 받지 못하면 실패 (NAS 가 멈춰도 영원히 기다리지 않게)
|
||||
data = await idleCall(channel.invokeMethod<Uint8List>(
|
||||
'readChunk', {'handle': handle, 'offset': offset, 'length': left == null ? chunk : min(chunk, left)}));
|
||||
data = await channel.invokeMethod<Uint8List>('readChunk', {'handle': handle, 'offset': offset, 'length': chunk});
|
||||
} on PlatformException catch (e) {
|
||||
throw _explain(e, rel);
|
||||
}
|
||||
if (data == null || data.isEmpty) break;
|
||||
offset += data.length;
|
||||
if (left != null) left -= data.length;
|
||||
yield data;
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -278,8 +239,8 @@ class SmbRemote implements RemoteClient {
|
|||
while (bytes.length - at >= chunk || (all && at < bytes.length)) {
|
||||
final end = (at + chunk).clamp(0, bytes.length);
|
||||
try {
|
||||
await idleCall(channel.invokeMethod<void>(
|
||||
'writeChunk', {'handle': handle, 'offset': offset, 'data': Uint8List.sublistView(bytes, at, end)}));
|
||||
await channel.invokeMethod<void>(
|
||||
'writeChunk', {'handle': handle, 'offset': offset, 'data': Uint8List.sublistView(bytes, at, end)});
|
||||
} on PlatformException catch (e) {
|
||||
throw _explain(e, rel);
|
||||
}
|
||||
|
|
@ -319,34 +280,14 @@ class SmbRegistry {
|
|||
static final _clients = <String, SmbRemote>{};
|
||||
static final _servers = <String, SmbServer>{};
|
||||
|
||||
/// 보안 검토 6: 서버 id → 지난 연결이 서명이나 암호화되었는지 (모르면 없음)
|
||||
static final _secure = <String, bool>{};
|
||||
|
||||
/// 서명 · 암호화가 안 된 연결이었는지 (탐색기 탭의 ⚠). 아직 연결하지 않았으면 false
|
||||
static bool insecure(String id) => _secure[id] == false;
|
||||
|
||||
/// 설정 "SMB 서명 · 암호화 필수" - 바뀌면 열려 있던 연결을 닫는다 (다음 연결부터 따르게)
|
||||
static bool _requireSecure = false;
|
||||
static bool get requireSecure => _requireSecure;
|
||||
static set requireSecure(bool v) {
|
||||
if (v == _requireSecure) return;
|
||||
_requireSecure = v;
|
||||
for (final c in _clients.values) {
|
||||
c.close();
|
||||
}
|
||||
_clients.clear();
|
||||
}
|
||||
|
||||
static void configure(List<SmbServer> servers) {
|
||||
final keep = {for (final s in servers) s.id: s};
|
||||
for (final id in _clients.keys.toList()) {
|
||||
final old = _servers[id], now = keep[id];
|
||||
if (now == null || old == null || old.password != now.password || jsonEncode(old.toJson()) != jsonEncode(now.toJson())) {
|
||||
_clients.remove(id)?.close();
|
||||
_secure.remove(id);
|
||||
}
|
||||
}
|
||||
_secure.removeWhere((id, _) => !keep.containsKey(id));
|
||||
_servers
|
||||
..clear()
|
||||
..addAll(keep);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import 'dart:io' show FileSystemEntity;
|
||||
|
||||
import 'file_ops.dart' show isAppTempName, isPartialFile, SourceUnreadableException;
|
||||
import 'rsync_filter.dart';
|
||||
import 'sync_tools.dart' show splitOptions, isSourceRemovingOption;
|
||||
|
||||
export 'sync_tools.dart' show isSourceRemovingOption;
|
||||
|
|
@ -26,9 +25,6 @@ enum PreviewAction {
|
|||
|
||||
/// 231: 원본의 링크 · junction - 앱이 직접 맞출 때는 따라가지 않고 건너뛴다 (214)
|
||||
skipLink,
|
||||
|
||||
/// M1 (10/19): 원본은 파일인데 받는 쪽에 같은 이름의 폴더가 있어 건너뜀 (--delete 가 없을 때 - 폴더를 지우지 않는다)
|
||||
skipDir,
|
||||
}
|
||||
|
||||
class PreviewItem {
|
||||
|
|
@ -40,12 +36,7 @@ class PreviewItem {
|
|||
final bool toRight;
|
||||
final bool isDir;
|
||||
final int size;
|
||||
const PreviewItem(this.rel, this.action,
|
||||
{required this.toRight, this.isDir = false, this.size = 0, this.newer = false, this.replacedByFile = false, this.inner = 0});
|
||||
|
||||
/// M1 (10/19): [PreviewAction.delete] 인 폴더가 원본의 같은 이름 파일로 바뀜 (--delete) - 그 폴더 안의 항목 수 [inner]
|
||||
final bool replacedByFile;
|
||||
final int inner;
|
||||
const PreviewItem(this.rel, this.action, {required this.toRight, this.isDir = false, this.size = 0, this.newer = false});
|
||||
|
||||
/// 229: [PreviewAction.update] 인데 받는 쪽이 더 새것 (예전 원본으로 새 파일을 덮어씀)
|
||||
final bool newer;
|
||||
|
|
@ -58,16 +49,6 @@ bool isRsyncDeleteOption(String o) => o == '--del' || o.startsWith('--delete') |
|
|||
/// rsync 옵션에 지우기 (--delete 계열) 가 있는지
|
||||
bool optionsDelete(String options) => splitOptions(options).any(isRsyncDeleteOption);
|
||||
|
||||
/// M2 (관리자 지시 10/19): rsync 옵션에 거르기 (--exclude · --include · --filter · -f · --exclude-from · --include-from · -C · -F) 가 있는지.
|
||||
/// 원격 (WebDAV · SFTP · SMB · FTP) 은 앱이 직접 맞추며 이 거르기를 따르지 않으므로 확인 창에 알린다
|
||||
bool optionsFilter(String options) => splitOptions(options).any((o) =>
|
||||
o == '-f' ||
|
||||
o == '-F' ||
|
||||
o == '--cvs-exclude' ||
|
||||
o == '-C' ||
|
||||
RegExp(r'^--(exclude|include|filter|exclude-from|include-from)(=|$)').hasMatch(o) ||
|
||||
(RegExp(r'^-[a-zA-Z]+$').hasMatch(o) && (o.contains('C') || o.contains('F'))));
|
||||
|
||||
/// 옵션 글을 낱말로 나누되 따옴표는 그대로 둔 원래 글 조각 (106: --exclude="My Folder" 가 갈라지지 않게)
|
||||
List<String> rawOptionTokens(String s) {
|
||||
final out = <String>[];
|
||||
|
|
@ -111,39 +92,10 @@ bool optionsUpdate(String options) =>
|
|||
/// [src] 안의 것 → [dst] 안으로 맞출 때 바뀌는 것. 원본을 읽지 못한 폴더에서는 "지워짐" 을 세지 않는다.
|
||||
/// [limit] 개를 넘으면 멈춘다 (화면에는 "… 이상").
|
||||
/// 231: 원본의 링크 · junction 은 안으로 들어가지 않는다. [skipLinks] (앱이 직접 - 현재 방식 · WebDAV) 면 "건너뜀 (링크)",
|
||||
/// 아니면 (rsync -a · robocopy) 링크 자체 하나로 센다.
|
||||
/// 177: [filter] = rsync 옵션의 거르기 규칙 (rsync 로 맞출 때만 - 앱이 직접 맞출 때는 따르지 않으므로 넘기지 않는다).
|
||||
/// 거른 것은 목록에 없고 (거른 폴더 안으로도 내려가지 않음), 받는 쪽의 거른 것은 지우지 않는다 (--delete-excluded 면 지움)
|
||||
/// 아니면 (rsync -a · robocopy) 링크 자체 하나로 센다
|
||||
Future<List<PreviewItem>> previewSync(String src, String dst,
|
||||
{required bool toRight,
|
||||
bool delete = false,
|
||||
bool update = false,
|
||||
bool skipLinks = false,
|
||||
RsyncFilter filter = RsyncFilter.none,
|
||||
int limit = 2000}) async {
|
||||
{required bool toRight, bool delete = false, bool update = false, bool skipLinks = false, int limit = 2000}) async {
|
||||
final out = <PreviewItem>[];
|
||||
// 177: 받는 쪽에만 있는 폴더 [dir] (상대 경로 [rel]) 안에서 거르지 않은 것만 지울 목록에 (거른 것이 든 폴더는 남김)
|
||||
Future<void> deleteInside(String dir, String rel) async {
|
||||
List<({String path, bool isDir, int size, DateTime modified})> items;
|
||||
try {
|
||||
items = await vList(dir);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
for (final x in items) {
|
||||
if (out.length >= limit) return;
|
||||
final name = vBasename(x.path);
|
||||
if (isAppTempName(name)) continue;
|
||||
final r = '$rel/$name';
|
||||
if (filter.protects(r, isDir: x.isDir)) continue;
|
||||
if (x.isDir && await _hasExcluded(x.path, r, filter)) {
|
||||
await deleteInside(x.path, r);
|
||||
} else {
|
||||
out.add(PreviewItem(r, PreviewAction.delete, toRight: toRight, isDir: x.isDir, size: x.size));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> walk(String s, String d, String rel) async {
|
||||
if (out.length >= limit) return;
|
||||
final there = <String, ({String path, bool isDir, int size, DateTime modified})>{};
|
||||
|
|
@ -165,10 +117,8 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
|
|||
if (out.length >= limit) return;
|
||||
final name = vBasename(e.path);
|
||||
if (isPartialFile(name) || isAppTempName(name)) continue;
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
// 177: 거른 것은 보내지 않는다 - 받는 쪽의 같은 이름은 아래에서 (지우지 않음 / --delete-excluded)
|
||||
if (filter.excluded(r, isDir: e.isDir)) continue;
|
||||
names.add(name);
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
final t = there[name];
|
||||
if (!isDav(e.path) && FileSystemEntity.isLinkSync(e.path)) {
|
||||
if (skipLinks) {
|
||||
|
|
@ -183,17 +133,6 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
|
|||
await walk(e.path, vJoin(d, name), r);
|
||||
continue;
|
||||
}
|
||||
// M1 (관리자 P0, 10/19): 받는 쪽의 같은 이름이 폴더 - --delete 면 폴더 (안의 것 모두) 를 지우고 파일로, 아니면 건너뜀 (앱 맞추기 · rsync 같음)
|
||||
if (t != null && t.isDir) {
|
||||
if (delete) {
|
||||
out.add(PreviewItem(r, PreviewAction.delete,
|
||||
toRight: toRight, isDir: true, replacedByFile: true, inner: await _countInside(t.path)));
|
||||
out.add(PreviewItem(r, PreviewAction.add, toRight: toRight, size: e.size));
|
||||
} else {
|
||||
out.add(PreviewItem(r, PreviewAction.skipDir, toRight: toRight, size: e.size));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (t == null) {
|
||||
out.add(PreviewItem(r, PreviewAction.add, toRight: toRight, size: e.size));
|
||||
} else if (t.size != e.size || t.modified.difference(e.modified).inSeconds.abs() > 2) {
|
||||
|
|
@ -208,19 +147,8 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
|
|||
}
|
||||
for (final e in there.values) {
|
||||
final name = vBasename(e.path);
|
||||
// 15 검토 F4: 받는 쪽에만 있는 받다 만 파일 (*.part 등) 도 --delete 면 지워진다 - 지울 목록에 (지우지 않으면 셀 것 없음)
|
||||
if (names.contains(name) || isAppTempName(name) || (isPartialFile(name) && !delete)) continue;
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
// 받는 쪽 규칙 (- · P · R · ,r) 에 걸린 것은 rsync 가 지우지 않는다. 관리자 지시 (10/20): 보내는 쪽에서만 거른 것
|
||||
// (H · -,s · --delete-excluded 의 그냥 규칙) 은 보내지 않았으므로 --delete 면 지운다. 지우지 않을 때는 거른 것을 세지 않음 (예전과 같음)
|
||||
if (filter.protects(r, isDir: e.isDir) || (!delete && filter.excluded(r, isDir: e.isDir))) continue;
|
||||
// 177 (28 확인): 받는 쪽에만 있는 폴더 안에 지키는 것이 있으면 rsync 는 그것을 지키느라 폴더를 남기고
|
||||
// 거르지 않은 것만 지운다 - 폴더 통째로 "지워짐" 이 아니라 안의 것만
|
||||
if (delete && e.isDir && filter.protectsAny && await _hasExcluded(e.path, r, filter)) {
|
||||
await deleteInside(e.path, r);
|
||||
continue;
|
||||
}
|
||||
out.add(PreviewItem(r, delete ? PreviewAction.delete : PreviewAction.onlyTarget,
|
||||
if (names.contains(name) || isPartialFile(name) || isAppTempName(name)) continue;
|
||||
out.add(PreviewItem(rel.isEmpty ? name : '$rel/$name', delete ? PreviewAction.delete : PreviewAction.onlyTarget,
|
||||
toRight: toRight, isDir: e.isDir, size: e.size));
|
||||
}
|
||||
}
|
||||
|
|
@ -229,31 +157,12 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
|
|||
return out;
|
||||
}
|
||||
|
||||
/// 177: 받는 쪽 폴더 [dir] (상대 경로 [rel]) 안 어딘가에 받는 쪽에서 지키는 것이 있는지 (지키는 폴더 안으로는 들어가지 않는다)
|
||||
Future<bool> _hasExcluded(String dir, String rel, RsyncFilter filter) async {
|
||||
List<({String path, bool isDir, int size, DateTime modified})> items;
|
||||
try {
|
||||
items = await vList(dir);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
for (final x in items) {
|
||||
final name = vBasename(x.path);
|
||||
if (isPartialFile(name) || isAppTempName(name)) continue;
|
||||
final r = '$rel/$name';
|
||||
if (filter.protects(r, isDir: x.isDir)) return true;
|
||||
if (x.isDir && await _hasExcluded(x.path, r, filter)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// ⇄ (양쪽 함께, -u): 두 방향을 합친다. 한쪽에만 있는 것은 다른 쪽으로 복사되므로 "한쪽에만 있음" 은 빼고,
|
||||
/// 양쪽에 있고 다른 파일은 더 새 쪽에서 받는 쪽으로 (한 번만).
|
||||
/// [delete]: --delete 와 함께면 먼저 도는 왼쪽 → 오른쪽에서 오른쪽에만 있는 것이 지워진다 (그 뒤 ← 로 건너가지 않음)
|
||||
Future<List<PreviewItem>> previewBoth(String left, String right,
|
||||
{bool delete = false, RsyncFilter filter = RsyncFilter.none, int limit = 2000}) async {
|
||||
final a = await previewSync(left, right, toRight: true, update: true, delete: delete, filter: filter, limit: limit);
|
||||
final b = await previewSync(right, left, toRight: false, update: true, filter: filter, limit: limit);
|
||||
Future<List<PreviewItem>> previewBoth(String left, String right, {bool delete = false, int limit = 2000}) async {
|
||||
final a = await previewSync(left, right, toRight: true, update: true, delete: delete, limit: limit);
|
||||
final b = await previewSync(right, left, toRight: false, update: true, limit: limit);
|
||||
final gone = {for (final x in a) if (x.action == PreviewAction.delete) x.rel};
|
||||
final seen = <String>{};
|
||||
return [
|
||||
|
|
@ -266,24 +175,3 @@ Future<List<PreviewItem>> previewBoth(String left, String right,
|
|||
x,
|
||||
];
|
||||
}
|
||||
|
||||
/// M1: 폴더 [dir] 안의 모든 항목 수 (지우고 파일로 바뀔 때 보이려고, [limit] 에서 멈춤)
|
||||
Future<int> _countInside(String dir, {int limit = 100000}) async {
|
||||
var n = 0;
|
||||
Future<void> go(String d) async {
|
||||
List<({String path, bool isDir, int size, DateTime modified})> items;
|
||||
try {
|
||||
items = await vList(d);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
for (final x in items) {
|
||||
if (n >= limit) return;
|
||||
n++;
|
||||
if (x.isDir) await go(x.path);
|
||||
}
|
||||
}
|
||||
|
||||
await go(dir);
|
||||
return n;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'file_ops.dart' show appTempRsyncExcludes;
|
||||
import '../l10n/tr.dart';
|
||||
|
||||
/// 파일 탐색기의 복사 · 이동 방법 (환경 설정 > 파일 탐색기 > 복사 · 동기화)
|
||||
///
|
||||
|
|
@ -24,400 +21,36 @@ enum CopyMethod {
|
|||
const defaultRsyncOptions = '-avPog';
|
||||
const defaultRobocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2';
|
||||
|
||||
/// 관리자 지시 (10/20): 화면에서 줄바꿈 없이 묶은 옵션 글 (sync_preview_view 의 unbreakableOption) 의 줄바꿈 없는 빈칸 (U+00A0 등) ·
|
||||
/// 보이지 않는 글자 (U+2060 등) 가 복사 · 붙여 넣기로 옵션에 들어오면 rsync 가 다른 인수로 읽는다 ("-", 보이지 않는 글자, "-filter") → 보통 빈칸으로 / 지움.
|
||||
/// 다른 곳 (웹 페이지 · 문서) 에서 붙여 넣은 것도 같다
|
||||
/// 28 확인 (10/20): 소프트 하이픈 (U+00AD) 은 지우고 (rsync 가 "unknown option" 으로 실패), 전각 빈칸 (U+3000) 도 보통 빈칸으로
|
||||
String cleanOptionText(String s) => s
|
||||
.replaceAll(RegExp('[\u00A0\u2007\u202F\u3000]'), ' ')
|
||||
.replaceAll(RegExp('[\u00AD\u200B\u200C\u200D\u2060\uFEFF]'), '');
|
||||
|
||||
/// 낱말 맨 앞에서 - 로 읽는 대시 (‐ ‑ ‒ – — ― − ﹘ ﹣ -) - 워드 · 웹이 -- 를 바꿔 놓은 것
|
||||
const _dashLike = '\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uFE58\uFE63\uFF0D';
|
||||
|
||||
/// 굽은 따옴표 → 곧은 따옴표 (같은 무리끼리 열고 닫는다)
|
||||
const _curlyQuote = {'\u2018': "'", '\u2019': "'", '\u201A': "'", '\u201C': '"', '\u201D': '"', '\u201E': '"'};
|
||||
|
||||
/// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로). 먼저 [cleanOptionText].
|
||||
/// 관리자 지시 (10/20, 28 · 15 확인): 웹 · 문서에서 붙여 넣은 글을 곧은 글자로 쓴 것과 같게 읽는다 - 예전에는
|
||||
/// --exclude=‘*.log’ 가 아무것도 거르지 못해 받는 쪽 keep.log 가 지워졌고, –delete 는 원본 경로로 넘어가 --delete 가 몰래 빠졌다.
|
||||
/// - 굽은 따옴표 (‘ ’ “ ”) 는 낱말 맨 앞 · = 뒤 · 짧은 옵션 (-f) 뒤에서 열고, 같은 무리의 굽은 따옴표에서 닫는다
|
||||
/// (곧은 따옴표처럼 뒤에 글이 이어져도 - “a b”.txt → a b.txt). 작은따옴표 무리는 글자 사이 (it’s) 이면 닫지 않는다.
|
||||
/// 이름 안의 굽은 따옴표 (it’s.txt) 는 그대로 둔다.
|
||||
/// - 닫히지 않은 따옴표 (15 확인 F1): 예전에는 뒤의 옵션을 모두 삼켜 지키려던 --exclude 가 사라졌다 → 그 따옴표를 글자로 보고
|
||||
/// 나머지는 그대로 나누며, [unreadableOptions] 가 알려 실행을 막는다.
|
||||
/// - 낱말 맨 앞의 대시 (– — ‑ - 등, 따옴표 밖): 둘이면 --, 하나면 rsync 의 긴 옵션 이름 (–delete) 일 때만 -- 그 밖 (–avz) 은 -.
|
||||
/// 옵션의 값 자리 (--exclude –x.txt 의 –x.txt) · 낱말 가운데의 대시는 그대로 (파일 이름). --filter · -f 값의 규칙 머리 (“– *.tmp”) 는 - 로.
|
||||
List<String> splitOptions(String s) => _parseOptions(s).words;
|
||||
|
||||
/// rsync 3.x 의 긴 옵션 이름 (앱에 든 rsync 3.5.1 의 --help) - 대시 하나로 붙여 넣은 –delete 를 --delete 로 읽을지 정한다
|
||||
const _rsyncLong = {
|
||||
'acls', 'address', 'append', 'append-verify', 'archive', 'atimes', 'backup', 'backup-dir', 'block-size', 'blocking-io',
|
||||
'bwlimit', 'cc', 'checksum', 'checksum-choice', 'checksum-seed', 'chmod', 'chown', 'compare-dest', 'compress',
|
||||
'compress-choice', 'compress-level', 'compress-threads', 'confine-root', 'contimeout', 'copy-as', 'copy-dest',
|
||||
'copy-devices', 'copy-dirlinks', 'copy-links', 'copy-unsafe-links', 'crtimes', 'cvs-exclude', 'daemon', 'debug', 'del',
|
||||
'delay-updates', 'delete', 'delete-after', 'delete-before', 'delete-delay', 'delete-during', 'delete-excluded',
|
||||
'delete-missing-args', 'devices', 'dirs', 'dry-run', 'early-input', 'exclude', 'exclude-from', 'executability', 'existing',
|
||||
'fake-super', 'files-from', 'filter', 'force', 'from0', 'fsync', 'fuzzy', 'group', 'groupmap', 'hard-links', 'help',
|
||||
'human-readable', 'i-r', 'iconv', 'ignore-errors', 'ignore-existing', 'ignore-missing-args', 'ignore-non-existing',
|
||||
'ignore-times', 'inc-recursive', 'include', 'include-from', 'info', 'inplace', 'insecure-links', 'ipv4', 'ipv6',
|
||||
'itemize-changes', 'keep-dirlinks', 'link-dest', 'links', 'list-only', 'log-file', 'log-file-format', 'max-alloc',
|
||||
'max-delete', 'max-size', 'min-size', 'mkpath', 'modify-window', 'munge-links', 'no-i-r', 'no-implied-dirs',
|
||||
'no-inc-recursive', 'no-motd', 'no-whole-file', 'numeric-ids', 'old-args', 'old-d', 'old-dirs', 'omit-dir-times',
|
||||
'omit-link-times', 'one-file-system', 'only-write-batch', 'open-noatime', 'out-format', 'outbuf', 'owner', 'partial',
|
||||
'partial-dir', 'password-file', 'perms', 'port', 'preallocate', 'progress', 'protocol', 'prune-empty-dirs', 'quiet',
|
||||
'read-batch', 'recursive', 'relative', 'remote-option', 'remove-source-files', 'rsh', 'rsync-path', 'safe-links',
|
||||
'secluded-args', 'size-only', 'skip-compress', 'sockopts', 'sparse', 'specials', 'stats', 'stderr', 'stop-after', 'stop-at',
|
||||
'suffix', 'super', 'temp-dir', 'timeout', 'times', 'trust-sender', 'update', 'usermap', 'verbose', 'version',
|
||||
'whole-file', 'write-batch', 'write-devices', 'xattrs', 'zc', 'zl', 'zt',
|
||||
};
|
||||
|
||||
/// 나눈 낱말: 맨 앞이 따옴표 안이었는지 (그러면 맨 앞 대시를 옵션으로 고치지 않는다)
|
||||
typedef _Word = ({String text, bool quoted});
|
||||
|
||||
/// [splitOptions] · [unreadableOptions] 의 몸통: 낱말들과 닫히지 않은 따옴표 (뒤 글 일부)
|
||||
({List<String> words, List<String> unclosed}) _parseOptions(String s) {
|
||||
final chars = cleanOptionText(s).split('');
|
||||
final literal = <int>{}; // 닫히지 않아 글자로 보는 따옴표 자리
|
||||
final unclosed = <String>[];
|
||||
while (true) {
|
||||
final (raw, open) = _splitRaw(chars, literal);
|
||||
if (open == null) return (words: _normalizeWords(raw), unclosed: unclosed);
|
||||
literal.add(open);
|
||||
final rest = chars.sublist(open).join().trim();
|
||||
unclosed.add(rest.length > 30 ? '${rest.substring(0, 30)}…' : rest);
|
||||
}
|
||||
}
|
||||
|
||||
/// 따옴표 · 빈칸으로 나눈다. 끝까지 닫히지 않은 따옴표가 있으면 그 자리 (두 번째 값)
|
||||
(List<_Word>, int?) _splitRaw(List<String> chars, Set<int> literal) {
|
||||
final out = <_Word>[];
|
||||
/// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로)
|
||||
List<String> splitOptions(String s) {
|
||||
final out = <String>[];
|
||||
final cur = StringBuffer();
|
||||
String? quote; // 닫는 곧은 따옴표 (" 또는 ')
|
||||
var curly = false; // 굽은 따옴표로 열었는지
|
||||
var openAt = -1;
|
||||
String? quote;
|
||||
var has = false;
|
||||
var quoted = false;
|
||||
bool letter(int i) => i >= 0 && i < chars.length && RegExp(r'[\p{L}\p{N}]', unicode: true).hasMatch(chars[i]);
|
||||
for (var i = 0; i < chars.length; i++) {
|
||||
final ch = chars[i];
|
||||
for (final ch in s.split('')) {
|
||||
if (quote != null) {
|
||||
// 작은따옴표 무리의 ’ 가 글자 사이면 (it’s) 닫지 않는다
|
||||
final closes = ch == quote && !curly ||
|
||||
curly && _curlyQuote[ch] == quote && !(quote == "'" && letter(i - 1) && letter(i + 1));
|
||||
if (closes) {
|
||||
if (ch == quote) {
|
||||
quote = null;
|
||||
curly = false;
|
||||
} else {
|
||||
cur.write(ch);
|
||||
}
|
||||
} else if (literal.contains(i)) {
|
||||
cur.write(ch);
|
||||
} else if (ch == '"' || ch == "'") {
|
||||
if (cur.isEmpty && !has) quoted = true;
|
||||
quote = ch;
|
||||
openAt = i;
|
||||
has = true;
|
||||
} else if (_curlyQuote.containsKey(ch) && (cur.isEmpty || '$cur'.endsWith('=') || RegExp(r'^-[A-Za-z0-9]+$').hasMatch('$cur'))) {
|
||||
if (cur.isEmpty && !has) quoted = true;
|
||||
quote = _curlyQuote[ch];
|
||||
curly = true;
|
||||
openAt = i;
|
||||
has = true;
|
||||
} else if (ch.trim().isEmpty) {
|
||||
if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted));
|
||||
if (cur.isNotEmpty || has) out.add(cur.toString());
|
||||
cur.clear();
|
||||
has = false;
|
||||
quoted = false;
|
||||
} else {
|
||||
cur.write(ch);
|
||||
}
|
||||
}
|
||||
if (quote != null) return (out, openAt);
|
||||
if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted));
|
||||
return (out, null);
|
||||
}
|
||||
|
||||
bool _dashAt(String t, int i) => i < t.length && (t[i] == '-' || _dashLike.contains(t[i]));
|
||||
|
||||
/// 거르기 규칙 머리 ("– *.tmp" · "+ *.txt") 를 rsync 가 읽는 - · + 로
|
||||
String _fixRuleHead(String v) {
|
||||
if (v.isEmpty) return v;
|
||||
if (_dashLike.contains(v[0])) return '-${v.substring(1)}';
|
||||
if (v[0] == '\uFF0B') return '+${v.substring(1)}';
|
||||
return v;
|
||||
}
|
||||
|
||||
/// 낱말 맨 앞 대시 고치기 · 거르기 값의 규칙 머리 고치기 (값 자리는 그대로)
|
||||
List<String> _normalizeWords(List<_Word> raw) {
|
||||
final out = <String>[];
|
||||
var value = ''; // 다음 낱말이 값: 'filter' (규칙 머리를 고침) · 'other' (그대로)
|
||||
for (final w in raw) {
|
||||
var t = w.text;
|
||||
if (value.isNotEmpty) {
|
||||
out.add(value == 'filter' ? _fixRuleHead(t) : t);
|
||||
value = '';
|
||||
continue;
|
||||
}
|
||||
// 28 확인 (10/20): 곧은 하이픈과 섞인 것 (-–delete) 도 맨 앞 둘 중 하나라도 대시면 같은 규칙으로
|
||||
if (!w.quoted && t.isNotEmpty && (_dashLike.contains(t[0]) || t.length > 1 && t[0] == '-' && _dashLike.contains(t[1]))) {
|
||||
final n = _dashAt(t, 1) ? 2 : 1;
|
||||
final rest = t.substring(n);
|
||||
final name = rest.split('=').first;
|
||||
final long = n == 2 || _rsyncLong.contains(name) || (name.startsWith('no-') && _rsyncLong.contains(name.substring(3)));
|
||||
t = '${long ? '--' : '-'}$rest';
|
||||
}
|
||||
if (t.startsWith('--filter=')) {
|
||||
t = '--filter=${_fixRuleHead(t.substring(9))}';
|
||||
} else if (t.length > 1 && t.startsWith('-') && !t.startsWith('--')) {
|
||||
final k = t.indexOf(RegExp('[BefMT@]'), 1);
|
||||
if (k > 0 && t[k] == 'f' && k < t.length - 1) t = '${t.substring(0, k + 1)}${_fixRuleHead(t.substring(k + 1))}';
|
||||
}
|
||||
out.add(t);
|
||||
if (t.startsWith('--')) {
|
||||
if (!t.contains('=') && _rsyncLongWithValue.contains(t)) value = t == '--filter' ? 'filter' : 'other';
|
||||
} else if (t.length > 1 && t.startsWith('-')) {
|
||||
final k = t.indexOf(RegExp('[BefMT@]'), 1);
|
||||
if (k == t.length - 1) value = t[k] == 'f' ? 'filter' : 'other';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// 값을 다음 낱말로 받는 rsync 긴 옵션 (--exclude *.log) - [unreadableOptions] 가 그 값을 옵션으로 보지 않게
|
||||
const _rsyncLongWithValue = {
|
||||
'--exclude', '--include', '--filter', '--exclude-from', '--include-from', '--files-from', '--backup-dir', '--suffix',
|
||||
'--log-file', '--log-file-format', '--out-format', '--chmod', '--chown', '--usermap', '--groupmap', '--bwlimit',
|
||||
'--timeout', '--contimeout', '--rsh', '--rsync-path', '--compare-dest', '--copy-dest', '--link-dest', '--partial-dir',
|
||||
'--temp-dir', '--max-size', '--min-size', '--max-delete', '--modify-window', '--password-file', '--iconv',
|
||||
'--block-size', '--checksum-choice', '--compress-choice', '--compress-level', '--skip-compress', '--info', '--debug',
|
||||
'--sockopts', '--port', '--address', '--outbuf', '--remote-option', '--max-alloc', '--stop-after', '--stop-at',
|
||||
'--write-batch', '--only-write-batch', '--read-batch', '--protocol', '--checksum-seed', '--copy-as', '--early-input',
|
||||
// 28 확인 (10/20): rsync --help 의 짧은 별칭 (aka --cc · --zc · --zl · --zt) 과 값 받는 옵션 - 빠져 있어 값이 "경로로 읽힙니다" 로 막혔다
|
||||
'--cc', '--zc', '--zl', '--zt', '--compress-threads', '--confine-root',
|
||||
};
|
||||
|
||||
/// 관리자 지시 (10/20, 28 · 15 확인): [splitOptions] 로 고쳐 읽은 뒤에도 rsync 가 다르게 읽을 것 - 있으면 확인 창 · 실시간 동기화에
|
||||
/// 빨갛게 알리고 실행을 막는다: 닫히지 않은 따옴표, 옵션 자리의 - 로 시작하지 않는 낱말 (rsync 는 원본 경로로 읽어 옵션이 몰래 빠짐),
|
||||
/// 하이픈 셋 이상, 끝에 값 없이 남은 값 받는 옵션. 옵션의 값 자리 (--exclude 한글.txt 의 한글.txt, -f 뒤) 는 세지 않는다
|
||||
List<String> unreadableOptions(String options, {bool? windows}) => [for (final x in optionProblems(options, windows: windows)) x.text];
|
||||
|
||||
/// 옵션 칸의 문제 종류 - 안내의 끝 문구 (고칠 방법) 를 고른다 (관리자 지시 10/20, 28 지적)
|
||||
enum OptionProblemKind { unclosed, path, noValue, odd, doubleQuote, relativePath, stdin }
|
||||
|
||||
/// [unreadableOptions] 의 한 줄과 그 종류
|
||||
class OptionProblem {
|
||||
final OptionProblemKind kind;
|
||||
final String text;
|
||||
|
||||
/// [text] 안의 옵션 낱말 (화면이 줄바꿈 없이 묶는다)
|
||||
final String token;
|
||||
const OptionProblem(this.kind, this.text, [this.token = '']);
|
||||
|
||||
@override
|
||||
String toString() => text;
|
||||
}
|
||||
|
||||
/// 문제 종류에 맞는 고칠 방법 (여러 종류면 · 로 잇는다)
|
||||
String optionProblemHint(Iterable<OptionProblem> problems, {bool? windows}) {
|
||||
windows ??= Platform.isWindows;
|
||||
final kinds = {for (final x in problems) x.kind};
|
||||
return [
|
||||
if (kinds.contains(OptionProblemKind.unclosed)) tr('따옴표를 닫아 주세요'),
|
||||
if (kinds.contains(OptionProblemKind.path)) tr('옵션은 - 나 -- 로 시작합니다'),
|
||||
if (kinds.contains(OptionProblemKind.noValue)) tr('값을 적어 주세요 (예: --exclude=*.tmp)'),
|
||||
if (kinds.contains(OptionProblemKind.odd)) tr('곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'),
|
||||
if (kinds.contains(OptionProblemKind.doubleQuote)) tr('큰따옴표 (") 를 빼 주세요'),
|
||||
// 관리자 지시 (10/20): Windows 는 드라이브로 시작하는 전체 경로만 ("/" 는 rsync 가 자기 설치 폴더 기준으로 읽음), Android 는 /
|
||||
if (kinds.contains(OptionProblemKind.relativePath)) windows ? tr('C:\\ 처럼 드라이브로 시작하는 전체 경로로') : tr('/ 로 시작하는 전체 경로로'),
|
||||
if (kinds.contains(OptionProblemKind.stdin)) tr('목록 파일의 전체 경로를 적어 주세요'),
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
/// "옵션을 알아볼 수 없습니다: … - 고칠 방법" (작업 기록 · 실시간 동기화 카드 · 설정 칸)
|
||||
String optionProblemMessage(List<OptionProblem> problems) =>
|
||||
trf('옵션을 알아볼 수 없습니다: {0} - {1}', [problems.join(' · '), optionProblemHint(problems)]);
|
||||
|
||||
/// [unreadableOptions] 의 몸통 (종류와 함께)
|
||||
List<OptionProblem> optionProblems(String options, {bool? windows}) {
|
||||
windows ??= Platform.isWindows;
|
||||
final parsed = _parseOptions(options);
|
||||
// 15 확인 F1: 닫히지 않은 따옴표 - 뒤의 옵션이 다르게 읽힐 수 있다
|
||||
final bad = [for (final u in parsed.unclosed) OptionProblem(OptionProblemKind.unclosed, trf('따옴표가 닫히지 않았습니다: {0}', [u]), u)];
|
||||
var value = false;
|
||||
var last = '';
|
||||
for (final t in parsed.words) {
|
||||
// P0 (10/20): Windows 의 rsync (MSYS2) 에는 큰따옴표가 든 인수를 그대로 넘길 수 없다 (prepareRsyncLaunch) - 값 자리도.
|
||||
// 닫히지 않은 따옴표로 이미 막혔으면 (그 따옴표가 낱말에 남음) 한 번만 알린다
|
||||
if (windows && parsed.unclosed.isEmpty && t.contains('"')) {
|
||||
bad.add(OptionProblem(OptionProblemKind.doubleQuote, trf('큰따옴표 (") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}', [t]), t));
|
||||
value = false;
|
||||
continue;
|
||||
}
|
||||
if (value) {
|
||||
value = false;
|
||||
continue;
|
||||
}
|
||||
if (t.startsWith('---')) {
|
||||
// 관리자 판단 (10/20, 28 확인): 하이픈이 셋 이상 (---delete) 은 rsync 가 알아보지 못한다 - 끝 문구는 "옵션은 - 나 -- 로 시작합니다"
|
||||
bad.add(OptionProblem(OptionProblemKind.path, t, t));
|
||||
} else if (t.startsWith('--')) {
|
||||
value = !t.contains('=') && _rsyncLongWithValue.contains(t);
|
||||
last = t;
|
||||
} else if (t.length > 1 && t.startsWith('-')) {
|
||||
// 짧은 옵션 묶음: 값을 받는 글자 (B · e · f · M · T · @) 가 끝이면 다음 낱말이 값 (rsync_filter 와 같은 규칙)
|
||||
final k = t.indexOf(RegExp('[BefMT@]'), 1);
|
||||
value = k == t.length - 1;
|
||||
last = t;
|
||||
} else {
|
||||
// 관리자 판단 (10/20, 28 확인): 옵션 칸에는 경로가 들어갈 자리가 없다 (원본 · 대상은 앱이 넣음) - 값 자리가 아닌데
|
||||
// - 로 시작하지 않는 낱말 (-a delete 의 delete, 혼자 쓴 -) 은 rsync 가 원본 경로로 읽어 옵션이 몰래 빠진다
|
||||
// ASCII 밖 글자로 시작하면 (« … 등) 붙여 넣은 이상한 글자 - 안내는 곧은 글자로 다시 적기
|
||||
bad.add(OptionProblem(t.codeUnitAt(0) > 0x7F ? OptionProblemKind.odd : OptionProblemKind.path, trf('경로로 읽힙니다: {0}', [t]), t));
|
||||
}
|
||||
}
|
||||
// 값이 필요한 옵션이 끝에 값 없이 (--exclude) - rsync 가 원본 경로를 그 값으로 먹는다
|
||||
if (value) bad.add(OptionProblem(OptionProblemKind.noValue, trf('{0} 의 값이 없습니다', [last]), last));
|
||||
// 관리자 판단 (10/20): 목록 파일의 상대 경로 - rsync 는 자기 작업 폴더 (Windows 는 응답 파일 폴더) 기준으로, 미리 보기는 앱의
|
||||
// 작업 폴더 기준으로 읽어 서로 다른 파일을 볼 수 있다. 전체 경로만 받는다
|
||||
for (final f in listFileValues(parsed.words)) {
|
||||
// 관리자 지시 (10/20, 28 지적): "-" (표준 입력) 는 따로 - 앱은 rsync 에 표준 입력을 주지 않는다
|
||||
if (f == '-') {
|
||||
bad.add(OptionProblem(OptionProblemKind.stdin, tr('표준 입력 (-) 은 쓸 수 없습니다'), f));
|
||||
} else if (!_absolutePath(f, windows: windows)) {
|
||||
bad.add(OptionProblem(OptionProblemKind.relativePath, trf('목록 파일은 전체 경로로 적어 주세요: {0}', [f]), f));
|
||||
}
|
||||
}
|
||||
// 관리자 지시 (10/20, 28 확인): 기록 · 배치 파일도 - 상대 경로면 응답 파일 임시 폴더에 생겼다가 함께 지워져 찾을 수 없다.
|
||||
// --backup-dir · --partial-dir · --temp-dir · --compare-dest 류는 받는 쪽 기준이라 그대로
|
||||
for (final f in _optionValues(parsed.words, const {'--log-file', '--write-batch', '--read-batch', '--only-write-batch'})) {
|
||||
if (!_absolutePath(f, windows: windows)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('기록 · 배치 파일은 전체 경로로 적어 주세요: {0}', [f]), f));
|
||||
}
|
||||
return bad;
|
||||
}
|
||||
|
||||
/// 긴 옵션 [names] 의 값 (--이름=값 · --이름 값)
|
||||
List<String> _optionValues(List<String> words, Set<String> names) => [
|
||||
for (var i = 0; i < words.length; i++)
|
||||
if (words[i].contains('=') && names.contains(words[i].substring(0, words[i].indexOf('='))))
|
||||
words[i].substring(words[i].indexOf('=') + 1)
|
||||
else if (names.contains(words[i]) && i + 1 < words.length)
|
||||
words[i + 1],
|
||||
];
|
||||
|
||||
/// 파일을 받는 옵션의 값 (--exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일").
|
||||
/// ": 이름" · dir-merge 는 폴더마다 읽는 파일 이름이라 (원래 상대) 빼고
|
||||
List<String> listFileValues(List<String> words) {
|
||||
const fileOpts = {'--exclude-from', '--include-from', '--files-from'};
|
||||
final out = <String>[];
|
||||
String? mergeFile(String rule) => RegExp(r'^(?:\.|merge)(?:,[^\s]*)?\s+(.+)$').firstMatch(rule.trim())?.group(1);
|
||||
for (var i = 0; i < words.length; i++) {
|
||||
final w = words[i];
|
||||
final next = i + 1 < words.length ? words[i + 1] : null;
|
||||
final eq = w.indexOf('=');
|
||||
if (w.startsWith('--') && eq > 0 && fileOpts.contains(w.substring(0, eq))) {
|
||||
out.add(w.substring(eq + 1));
|
||||
} else if (fileOpts.contains(w) && next != null) {
|
||||
out.add(next);
|
||||
i++;
|
||||
} else if (w.startsWith('--filter=')) {
|
||||
final f = mergeFile(w.substring(9));
|
||||
if (f != null) out.add(f);
|
||||
} else if (w == '--filter' && next != null) {
|
||||
final f = mergeFile(next);
|
||||
if (f != null) out.add(f);
|
||||
i++;
|
||||
} else if (!w.startsWith('--') && w.length > 1 && w.startsWith('-')) {
|
||||
// 짧은 옵션 묶음의 f: 붙은 값 (-f". 목록") 또는 다음 낱말 (-avf ". 목록")
|
||||
final k = w.indexOf(RegExp('[BefMT@]'), 1);
|
||||
if (k < 0 || w[k] != 'f') continue;
|
||||
final rule = k == w.length - 1 ? next : w.substring(k + 1);
|
||||
if (k == w.length - 1) i++;
|
||||
final f = rule == null ? null : mergeFile(rule);
|
||||
if (f != null) out.add(f);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// 전체 경로. Windows: 드라이브 (C:\ · C:/) · UNC (\\서버 · //서버) · /cygdrive/… - 그 밖의 / 는 MSYS 가 자기 설치 폴더 기준으로 읽어 뺀다.
|
||||
/// Android: / 로 시작
|
||||
bool _absolutePath(String f, {required bool windows}) =>
|
||||
windows ? RegExp(r'^(?:[A-Za-z]:[\\/]|\\\\|//|/cygdrive/)').hasMatch(f) : f.startsWith('/');
|
||||
|
||||
/// 관리자 판단 (10/20, 28 확인): rsync (Windows MSYS2) 는 목록 · 기록 · 배치 파일의 C:\… · C:/… · \\서버\… 를 열지 못한다
|
||||
/// ("failed to open exclude file", exit 11) - 그 값들을 경로처럼 [toCygwinPath] 로 바꿔 넘긴다 (막지 않음).
|
||||
/// 대상: --exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일" · --log-file · --write-batch · --read-batch · --only-write-batch
|
||||
List<String> rsyncFileOptionArgs(List<String> words, {required bool windows}) {
|
||||
if (!windows) return words;
|
||||
const opts = {
|
||||
'--exclude-from', '--include-from', '--files-from', '--log-file', '--write-batch', '--read-batch', '--only-write-batch',
|
||||
};
|
||||
// 28 · 15 확인 (10/20, MSYS 3.5.1): 이 파일들은 UNC 를 \\서버\… 로만 연다 (//서버/… 는 "failed to open", exit 11) - UNC 는 \\ 로,
|
||||
// 드라이브는 /cygdrive/… 로
|
||||
String conv(String v) => RegExp(r'^[A-Za-z]:[\\/]').hasMatch(v)
|
||||
? toCygwinPath(v)
|
||||
: RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(v)
|
||||
? v.replaceAll('/', '\\')
|
||||
: v;
|
||||
String rule(String r) {
|
||||
final m = RegExp(r'^(\s*(?:\.|merge)(?:,[^\s]*)?\s+)(.+)$').firstMatch(r);
|
||||
return m == null ? r : '${m[1]}${conv(m[2]!)}';
|
||||
}
|
||||
|
||||
final out = <String>[];
|
||||
for (var i = 0; i < words.length; i++) {
|
||||
final w = words[i];
|
||||
final next = i + 1 < words.length ? words[i + 1] : null;
|
||||
final eq = w.indexOf('=');
|
||||
if (w.startsWith('--') && eq > 0 && opts.contains(w.substring(0, eq))) {
|
||||
out.add('${w.substring(0, eq + 1)}${conv(w.substring(eq + 1))}');
|
||||
} else if (opts.contains(w) && next != null) {
|
||||
out
|
||||
..add(w)
|
||||
..add(conv(next));
|
||||
i++;
|
||||
} else if (w.startsWith('--filter=')) {
|
||||
out.add('--filter=${rule(w.substring(9))}');
|
||||
} else if (w == '--filter' && next != null) {
|
||||
out
|
||||
..add(w)
|
||||
..add(rule(next));
|
||||
i++;
|
||||
} else if (!w.startsWith('--') && w.length > 1 && w.startsWith('-')) {
|
||||
// 짧은 옵션 묶음의 f: 붙은 값 (-f". 목록") 또는 다음 낱말 (-avf ". 목록")
|
||||
final k = w.indexOf(RegExp('[BefMT@]'), 1);
|
||||
if (k >= 0 && w[k] == 'f' && k < w.length - 1) {
|
||||
out.add('${w.substring(0, k + 1)}${rule(w.substring(k + 1))}');
|
||||
} else if (k >= 0 && w[k] == 'f' && next != null) {
|
||||
out
|
||||
..add(w)
|
||||
..add(rule(next));
|
||||
i++;
|
||||
} else {
|
||||
out.add(w);
|
||||
// 다른 값 받는 글자 (-e ssh 등) 의 값은 그대로 건너뜀
|
||||
if (k == w.length - 1 && next != null) {
|
||||
out.add(next);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.add(w);
|
||||
}
|
||||
}
|
||||
if (cur.isNotEmpty || has) out.add(cur.toString());
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Windows 경로 → MSYS2 rsync 가 읽는 경로 (C:\a\b → /cygdrive/c/a/b, \\server\share\x → //server/share/x).
|
||||
/// MSYS2 rsync 는 "C:" 를 원격 컴퓨터 이름으로 읽으므로 꼭 바꿔야 한다.
|
||||
/// 받는 쪽 (대상) 경로를 rsync 에 넘길 꼴로. 28 · 15 확인 (10/20, MSYS 3.5.1): 받는 쪽의 UNC 는 \\서버\… 만 된다
|
||||
/// (//서버/… 는 change_dir 실패, exit 3) - 원본은 거꾸로 //서버/… 만 되어 [toCygwinPath] 그대로
|
||||
String rsyncDestPath(String path, {bool windows = true}) {
|
||||
if (windows && RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(path)) return path.replaceAll('/', '\\');
|
||||
return toCygwinPath(path, windows: windows);
|
||||
}
|
||||
|
||||
String toCygwinPath(String path, {bool windows = true}) {
|
||||
if (!windows) return path;
|
||||
final s = path.replaceAll('\\', '/');
|
||||
|
|
@ -445,7 +78,7 @@ List<String> rsyncArgs({
|
|||
bool contents = false,
|
||||
}) {
|
||||
// 178: 원본을 지우는 옵션은 [move] (이동 · [원본 파일 지우기]) 로만 - 옵션 글에 있어도 빼고, 이동일 때만 넣는다
|
||||
final opts = rsyncFileOptionArgs([for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o], windows: windows);
|
||||
final opts = [for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o];
|
||||
return [
|
||||
...opts,
|
||||
if (bandwidthKBps > 0 && !opts.any((o) => o.startsWith('--bwlimit'))) '--bwlimit=$bandwidthKBps',
|
||||
|
|
@ -455,7 +88,7 @@ List<String> rsyncArgs({
|
|||
...appTempRsyncExcludes, // 78 보충: 일괄 이름 바꾸기 중의 임시 이름 (~jjren_ · ~jjback_) 은 보내지 않는다
|
||||
// [contents]: 폴더 "안의 것" 을 대상에 (rsync 의 끝 / - 동기화 · lsync 와 같은 모양)
|
||||
for (final s in sources) '${toCygwinPath(_noTrailingSlash(s), windows: windows)}${contents ? '/' : ''}',
|
||||
'${rsyncDestPath(_noTrailingSlash(dest), windows: windows)}/',
|
||||
'${toCygwinPath(_noTrailingSlash(dest), windows: windows)}/',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,13 +3,11 @@ import 'dart:io';
|
|||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'ftp.dart';
|
||||
import 'remote_fs.dart';
|
||||
import 'sftp.dart';
|
||||
import 'smb.dart';
|
||||
import 'webdav.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import 'remote_idle.dart';
|
||||
|
||||
/// 로컬 경로와 원격 경로 (WebDAV `dav://<서버 id>/<경로>` · 88 SFTP `sftp://<서버 id>/<경로>`) 를 같이 다루는 도구.
|
||||
/// 로컬 경로는 원래 코드 (dart:io) 를 그대로 쓰고, 원격만 여기서 서버 종류에 맞는 클라이언트 ([RemoteClient]) 로 처리한다.
|
||||
|
|
@ -19,21 +17,16 @@ const sftpScheme = 'sftp://';
|
|||
/// 88: Android SMB (smbj) `smb://<서버 id>/<경로>` - Windows 는 운영체제의 \\서버\공유 를 그대로 쓴다
|
||||
const smbScheme = 'smb://';
|
||||
|
||||
/// l: FTP · FTPS `ftp://<서버 id>/<경로>` (dart:io 로 직접 - lib/core/ftp.dart)
|
||||
const ftpScheme = 'ftp://';
|
||||
|
||||
/// 원격 (WebDAV · SFTP · SMB) 경로인지. 이름은 예전 그대로 (원격이면 같은 길 - 복사 · 동기화 · 임시로 받아 열기)
|
||||
bool isDav(String path) =>
|
||||
path.startsWith(davScheme) || path.startsWith(sftpScheme) || path.startsWith(smbScheme) || path.startsWith(ftpScheme);
|
||||
bool isDav(String path) => path.startsWith(davScheme) || path.startsWith(sftpScheme) || path.startsWith(smbScheme);
|
||||
|
||||
/// WebDAV 만 (HTTP 로 바로 재생 · 서버 고치기 창 등 WebDAV 에만 있는 것)
|
||||
bool isWebDav(String path) => path.startsWith(davScheme);
|
||||
bool isSftp(String path) => path.startsWith(sftpScheme);
|
||||
bool isSmb(String path) => path.startsWith(smbScheme);
|
||||
bool isFtp(String path) => path.startsWith(ftpScheme);
|
||||
|
||||
/// 스트리밍 주소가 없는 원격 (SFTP · SMB) - 재생 · 다른 앱으로 열기는 받아서
|
||||
bool noStreamUrl(String path) => isSftp(path) || isSmb(path) || isFtp(path);
|
||||
bool noStreamUrl(String path) => isSftp(path) || isSmb(path);
|
||||
|
||||
/// 원격 경로: 서버 종류 ([scheme]) · 서버 id · 서버 기준 경로 ("/" 로 시작, 끝 / 없음, 맨 위는 "/")
|
||||
class DavPath {
|
||||
|
|
@ -48,9 +41,7 @@ class DavPath {
|
|||
? sftpScheme
|
||||
: path.startsWith(smbScheme)
|
||||
? smbScheme
|
||||
: path.startsWith(ftpScheme)
|
||||
? ftpScheme
|
||||
: davScheme;
|
||||
: davScheme;
|
||||
var rest = path.substring(scheme.length).replaceAll('\\', '/');
|
||||
final slash = rest.indexOf('/');
|
||||
final server = slash < 0 ? rest : rest.substring(0, slash);
|
||||
|
|
@ -58,15 +49,12 @@ class DavPath {
|
|||
rel = rel.replaceAll(RegExp(r'/+'), '/');
|
||||
if (rel.length > 1 && rel.endsWith('/')) rel = rel.substring(0, rel.length - 1);
|
||||
if (rel.isEmpty) rel = '/';
|
||||
// 보안 검토 1: ".." 마디는 받지 않는다 (서버 밖 · 로컬 임시 폴더 밖으로 나가지 않게)
|
||||
if (rel.split('/').any((s) => s == '..' || s.contains('\u0000'))) throw FileSystemException(tr('쓸 수 없는 이름입니다'), path);
|
||||
return DavPath(server, rel, scheme);
|
||||
}
|
||||
|
||||
bool get isRoot => rel == '/';
|
||||
bool get isSftp => scheme == sftpScheme;
|
||||
bool get isSmb => scheme == smbScheme;
|
||||
bool get isFtp => scheme == ftpScheme;
|
||||
String get full => isRoot ? '$scheme$server/' : '$scheme$server$rel';
|
||||
String get name => isRoot ? '' : rel.substring(rel.lastIndexOf('/') + 1);
|
||||
DavPath get parent => isRoot ? this : DavPath(server, rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/')), scheme);
|
||||
|
|
@ -77,38 +65,24 @@ class DavPath {
|
|||
? SftpRegistry.client(server)
|
||||
: isSmb
|
||||
? SmbRegistry.client(server)
|
||||
: isFtp
|
||||
? FtpRegistry.client(server)
|
||||
: DavRegistry.client(server);
|
||||
: DavRegistry.client(server);
|
||||
|
||||
/// 서버 이름 (화면 표시)
|
||||
String get serverLabel => isSftp
|
||||
? SftpRegistry.server(server)?.label ?? 'SFTP'
|
||||
: isSmb
|
||||
? SmbRegistry.server(server)?.label ?? 'SMB'
|
||||
: isFtp
|
||||
? FtpRegistry.server(server)?.label ?? 'FTP'
|
||||
: DavRegistry.server(server)?.label ?? 'WebDAV';
|
||||
: DavRegistry.server(server)?.label ?? 'WebDAV';
|
||||
|
||||
/// 설정에 이 서버가 있는지 (지운 서버의 경로 · 즐겨찾기 등)
|
||||
bool get serverKnown => isSftp
|
||||
? SftpRegistry.server(server) != null
|
||||
: isSmb
|
||||
? SmbRegistry.server(server) != null
|
||||
: isFtp
|
||||
? FtpRegistry.server(server) != null
|
||||
: DavRegistry.server(server) != null;
|
||||
: DavRegistry.server(server) != null;
|
||||
|
||||
/// 화면 표시 아이콘 글자: WebDAV ☁ · SFTP 🔑 · SMB 🗄
|
||||
/// l: FTP 📂 (평문 FTP 는 ⚠ - 암호화 안 됨)
|
||||
String get mark => isSftp
|
||||
? '🔑'
|
||||
: isSmb
|
||||
// 보안 검토 6: 서명 · 암호화가 안 된 연결이면 ⚠
|
||||
? (SmbRegistry.insecure(server) ? '⚠' : '🗄')
|
||||
: isFtp
|
||||
? (FtpRegistry.server(server)?.tls ?? true ? '📂' : '⚠')
|
||||
: '☁';
|
||||
String get mark => isSftp ? '🔑' : isSmb ? '🗄' : '☁';
|
||||
}
|
||||
|
||||
/// WebDAV 파일을 받지 않고 바로 재생 (스트리밍) 할 주소 · 헤더 (인증은 헤더로만 - 주소에 비밀번호를 넣지 않는다)
|
||||
|
|
@ -177,10 +151,7 @@ Future<List<({String path, bool isDir, int size, DateTime modified})>> vList(Str
|
|||
final d = DavPath.parse(dir);
|
||||
return [
|
||||
for (final i in await d.client.list(d.rel))
|
||||
// 보안 검토 1: 서버가 보낸 이름이 이 폴더 바로 아래의 평범한 이름일 때만 ("..", "/", "\" 가 든 이름은 버린다 -
|
||||
// 악성 서버가 "..\..\x" 같은 이름으로 이 기기의 다른 폴더에 쓰거나 맞추기 (--delete) 로 지우지 않게)
|
||||
if (remoteChildOk(d.rel, i.rel))
|
||||
(path: DavPath(d.server, i.rel, d.scheme).full, isDir: i.isDir, size: i.size, modified: i.modified),
|
||||
(path: DavPath(d.server, i.rel, d.scheme).full, isDir: i.isDir, size: i.size, modified: i.modified),
|
||||
];
|
||||
}
|
||||
final out = <({String path, bool isDir, int size, DateTime modified})>[];
|
||||
|
|
@ -236,9 +207,8 @@ Future<void> vWrite(String path, Stream<List<int>> data, {int? length, DateTime?
|
|||
try {
|
||||
await d.client.write(tmp.rel, data, length: length);
|
||||
} catch (_) {
|
||||
// 받다 만 .jjpart 를 지운다 - 서버가 멈췄으면 지우기도 답이 없을 수 있어 정한 시간까지만 (못 지우면 다음 정리 (71) 에서)
|
||||
try {
|
||||
await idleCall(d.client.delete(tmp.rel));
|
||||
await d.client.delete(tmp.rel);
|
||||
} catch (_) {}
|
||||
rethrow;
|
||||
}
|
||||
|
|
@ -250,18 +220,15 @@ Future<void> vWrite(String path, Stream<List<int>> data, {int? length, DateTime?
|
|||
try {
|
||||
await out.addStream(data);
|
||||
} catch (_) {
|
||||
// 실패 · 취소: 반쯤 쓴 임시 파일을 남기지 않는다 (원래 파일은 그대로).
|
||||
// 10/20 (15, 멈추는 서버 시험으로 찾음): 예전에는 finally 에서 한 번 더 닫다가 난 "File closed" 가 진짜 이유
|
||||
// (서버가 답하지 않습니다 · 연결 끊김 등) 를 덮어썼다 - 닫기 · 지우기의 오류는 삼키고 원래 오류를 그대로
|
||||
try {
|
||||
await out.close();
|
||||
} catch (_) {}
|
||||
// 실패 · 취소: 반쯤 쓴 임시 파일을 남기지 않는다 (원래 파일은 그대로)
|
||||
await out.close();
|
||||
try {
|
||||
await File(tmp).delete();
|
||||
} catch (_) {}
|
||||
rethrow;
|
||||
} finally {
|
||||
await out.close();
|
||||
}
|
||||
await out.close();
|
||||
if (await File(path).exists()) await File(path).delete();
|
||||
await File(tmp).rename(path);
|
||||
if (modified != null) {
|
||||
|
|
@ -299,27 +266,14 @@ Future<String> vUniqueTarget(String dir, String name) async {
|
|||
}
|
||||
}
|
||||
|
||||
/// 보안 검토 1: 원격 목록의 [child] 가 [parent] 바로 아래의 평범한 이름인지 (비지 않음 · "." · ".." 아님 · "/" "\" NUL 없음)
|
||||
bool remoteChildOk(String parent, String child) {
|
||||
final cut = child.lastIndexOf('/');
|
||||
if (cut < 0) return false;
|
||||
final name = child.substring(cut + 1);
|
||||
final dir = cut == 0 ? '/' : child.substring(0, cut);
|
||||
final want = parent.length > 1 && parent.endsWith('/') ? parent.substring(0, parent.length - 1) : parent;
|
||||
return dir == want && name.isNotEmpty && name != '.' && name != '..' && !name.contains('\\') && !name.contains('\u0000');
|
||||
}
|
||||
|
||||
/// 원격 파일을 열거나 재생하려면 로컬로 받아야 한다: 임시 폴더에 받은 경로 (로컬이면 그대로)
|
||||
Future<String> vLocalCopy(String path, String tempDir, {void Function(int done, int total)? onProgress}) async {
|
||||
if (!isDav(path)) return path;
|
||||
final d = DavPath.parse(path);
|
||||
final base = p.join(tempDir, d.isSftp ? 'jj_sftp' : d.isSmb ? 'jj_smb' : d.isFtp ? 'jj_ftp' : 'jj_webdav');
|
||||
final dir = Directory(p.join(base, d.server, p.dirname(d.rel.substring(1))));
|
||||
final out = File(p.join(dir.path, d.name));
|
||||
// 보안 검토 1: 받은 파일은 늘 임시 폴더 안에 (서버 id · 경로가 어떻든)
|
||||
if (d.name.isEmpty || !p.isWithin(base, p.normalize(out.path))) throw FileSystemException(tr('쓸 수 없는 이름입니다'), path);
|
||||
final st = await d.client.stat(d.rel);
|
||||
final dir = Directory(p.join(tempDir, d.isSftp ? 'jj_sftp' : d.isSmb ? 'jj_smb' : 'jj_webdav', d.server, p.dirname(d.rel.substring(1))));
|
||||
await dir.create(recursive: true);
|
||||
final out = File(p.join(dir.path, d.name));
|
||||
// 이미 받아 둔 것 (크기 · 바뀐 때 같음) 은 다시 받지 않는다
|
||||
if (st != null && await out.exists() && await out.length() == st.size && !(await out.lastModified()).isBefore(st.modified)) {
|
||||
return out.path;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import 'package:xml/xml.dart';
|
|||
import '../l10n/tr.dart';
|
||||
import 'remote_fs.dart';
|
||||
import 'secret_gate.dart';
|
||||
import 'remote_idle.dart';
|
||||
|
||||
/// WebDAV 서버 설정 (환경 설정 > 파일 탐색기 > WebDAV). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
|
||||
class DavServer {
|
||||
|
|
@ -116,10 +115,6 @@ class DavClient implements RemoteClient {
|
|||
? null
|
||||
: 'Basic ${base64Encode(utf8.encode('${server.user}:${server.password}'))}';
|
||||
|
||||
/// 관리자 판단 (10/20): 답 (머리) 을 정한 시간 ([RemoteIdle]) 까지만 기다린다 - 서버가 멈추면 그 연결을 끊고 "서버가 답하지 않습니다".
|
||||
/// 답의 몸은 [idleRead] 로 (조각 사이)
|
||||
Future<HttpClientResponse> _send(HttpClientRequest req) => idleCall(req.close(), onTimeout: req.abort);
|
||||
|
||||
Future<HttpClientRequest> _open(String method, Uri uri) async {
|
||||
// 124: 저장된 비밀번호로 접속하기 전에 (마스터 비밀번호를 정해 두었으면 묻는다)
|
||||
if (server.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage));
|
||||
|
|
@ -169,8 +164,8 @@ class DavClient implements RemoteClient {
|
|||
final bytes = utf8.encode(body);
|
||||
req.contentLength = bytes.length;
|
||||
req.add(bytes);
|
||||
final res = await _send(req);
|
||||
final text = await idleRead(res).transform(utf8.decoder).join();
|
||||
final res = await req.close();
|
||||
final text = await res.transform(utf8.decoder).join();
|
||||
if (res.statusCode == 404) throw DavException(trf('없는 경로: {0}', [rel]), status: 404);
|
||||
if (res.statusCode != 207 && res.statusCode != 200) _fail(tr('목록'), res, text);
|
||||
return text;
|
||||
|
|
@ -236,8 +231,8 @@ class DavClient implements RemoteClient {
|
|||
|
||||
/// 폴더 만들기 (이미 있으면 그대로)
|
||||
Future<void> mkdir(String rel) async {
|
||||
final res = await _send(await _open('MKCOL', uriOf(rel, dir: true)));
|
||||
await idleRead(res).drain<void>();
|
||||
final res = await (await _open('MKCOL', uriOf(rel, dir: true))).close();
|
||||
await res.drain<void>();
|
||||
if (res.statusCode == 201 || res.statusCode == 200) return;
|
||||
if (res.statusCode == 405 && (await stat(rel))?.isDir == true) return; // 이미 있음
|
||||
_fail(tr('폴더 만들기'), res);
|
||||
|
|
@ -257,8 +252,8 @@ class DavClient implements RemoteClient {
|
|||
@override
|
||||
Future<void> delete(String rel) async {
|
||||
final st = await stat(rel);
|
||||
final res = await _send(await _open('DELETE', uriOf(rel, dir: st?.isDir ?? false)));
|
||||
await idleRead(res).drain<void>();
|
||||
final res = await (await _open('DELETE', uriOf(rel, dir: st?.isDir ?? false))).close();
|
||||
await res.drain<void>();
|
||||
if (res.statusCode == 404) return;
|
||||
if (res.statusCode >= 300) _fail(tr('지우기'), res);
|
||||
}
|
||||
|
|
@ -270,8 +265,8 @@ class DavClient implements RemoteClient {
|
|||
..set('Destination', uriOf(to, dir: st?.isDir ?? false).toString())
|
||||
..set('Overwrite', overwrite ? 'T' : 'F');
|
||||
if (st?.isDir ?? false) req.headers.set('Depth', 'infinity');
|
||||
final res = await _send(req);
|
||||
await idleRead(res).drain<void>();
|
||||
final res = await req.close();
|
||||
await res.drain<void>();
|
||||
if (res.statusCode >= 300) _fail(method == 'MOVE' ? tr('이동') : tr('복사'), res);
|
||||
}
|
||||
|
||||
|
|
@ -282,14 +277,12 @@ class DavClient implements RemoteClient {
|
|||
/// 받기: 내용을 조각으로 (끝나면 스트림이 닫힘)
|
||||
@override
|
||||
Future<Stream<List<int>>> openRead(String rel) async {
|
||||
// 관리자 판단 (10/20): 답 (머리) 과 조각 사이를 정한 시간까지만 기다린다 - 서버가 멈추면 연결을 끊고 실패
|
||||
final req = await _open('GET', uriOf(rel));
|
||||
final res = await _send(req);
|
||||
final res = await (await _open('GET', uriOf(rel))).close();
|
||||
if (res.statusCode != 200) {
|
||||
await res.drain<void>();
|
||||
_fail(tr('받기'), res);
|
||||
}
|
||||
return idleRead(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
/// 올리기 ([length] 를 알면 Content-Length 로)
|
||||
|
|
@ -297,11 +290,8 @@ class DavClient implements RemoteClient {
|
|||
Future<void> write(String rel, Stream<List<int>> data, {int? length}) async {
|
||||
final req = await _open('PUT', uriOf(rel));
|
||||
if (length != null) req.contentLength = length;
|
||||
// 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하거나 다 보낸 뒤 답이 없으면 실패 (연결을 끊음)
|
||||
final res = await idleWrite(data, (x) async {
|
||||
await req.addStream(x);
|
||||
return req.close();
|
||||
}, onTimeout: req.abort);
|
||||
await req.addStream(data);
|
||||
final res = await req.close();
|
||||
await res.drain<void>();
|
||||
if (res.statusCode >= 300) _fail(tr('올리기'), res);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,514 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../l10n/tr.dart';
|
||||
import 'file_ops.dart' show formatSize, isAppTempName, isPartialFile;
|
||||
import 'lan_scan.dart' show isLanAddress, isMobileInterfaceName, netKindsOf;
|
||||
|
||||
/// 89: Wi-Fi 공유 - 앱 안 작은 HTTP 서버로 고른 폴더 하나를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용).
|
||||
/// 사용자 결정 (10/9): 기본 꺼짐 · 읽기 전용 · 접속 비밀번호. 관리자 지시 (10/17):
|
||||
/// - 켤 때마다 6자리 코드를 새로 만든다 (주소 · QR · 코드를 화면에)
|
||||
/// - 고른 폴더 밖은 막는다 (../ · 절대 경로 · 링크 · junction 을 따라가지 않음)
|
||||
/// - 정한 시간이 지나면 저절로 꺼진다 (처음 30분). 앱을 끄면 함께 꺼진다
|
||||
/// - 접속 기록은 화면에만 (작업 기록 파일에 남기지 않음), 코드는 어디에도 기록하지 않는다
|
||||
/// 보안 (관리자 판단 · 테스터 15):
|
||||
/// - GET · HEAD 만 (그 밖은 405). 올리기 · 지우기 · 이름 바꾸기 없음
|
||||
/// - 코드를 틀리면 그 기기는 5번에 1분 막고, 모두 합쳐 30번 틀리면 공유를 끈다 (6자리 무작위 대입 막기)
|
||||
/// - 사설망 (10/8 · 172.16/12 · 192.168/16 · 169.254/16 · 100.64/10 · 이 기기) 에서 온 요청만 받는다
|
||||
/// - 공유한 파일은 격리해 보낸다 (CSP sandbox · nosniff, HTML · SVG · JS 는 내려받기로) - 공유 폴더 안의 웹 페이지가
|
||||
/// 접속한 브라우저에서 스크립트를 돌리지 못하게
|
||||
class WifiShare extends ChangeNotifier {
|
||||
/// 지금 켜진 공유 (한 번에 하나)
|
||||
static WifiShare? get instance => current.value;
|
||||
|
||||
/// 켜고 끌 때 알림 (탐색기 위 줄 · Android 알림이 듣는다)
|
||||
static final current = ValueNotifier<WifiShare?>(null);
|
||||
|
||||
/// 처음 해 볼 포트 (쓰는 중이면 빈 포트)
|
||||
static const defaultPort = 8765;
|
||||
|
||||
/// 틀린 코드: 한 기기에서 [perClientFails] 번이면 [lockout] 동안 막고, 모두 [totalFails] 번이면 끈다
|
||||
static const perClientFails = 5;
|
||||
static const lockout = Duration(minutes: 1);
|
||||
static const totalFails = 30;
|
||||
|
||||
/// 시험: 화면 시험에서 이 기기 (127.0.0.1) 에만 연다 (시험 PC 의 방화벽 창 · 바깥 노출 없이)
|
||||
@visibleForTesting
|
||||
static bool debugLoopbackOnly = false;
|
||||
|
||||
/// 시험: 이 기기의 주소 대신 쓸 것 (화면 캡처에 시험 PC 의 실제 망 주소가 남지 않게)
|
||||
@visibleForTesting
|
||||
static List<String>? debugAddresses;
|
||||
|
||||
/// 화면에 보이는 접속 기록 개수
|
||||
static const maxLog = 200;
|
||||
|
||||
final String root;
|
||||
final String _rootReal;
|
||||
final String code;
|
||||
/// 주소마다 하나 (보안 검토 3: 모든 연결 (0.0.0.0) 이 아니라 화면에 보인 Wi-Fi · 유선 주소에만 연다)
|
||||
final List<HttpServer> _servers;
|
||||
final Random _rng;
|
||||
final DateTime started;
|
||||
DateTime? endsAt;
|
||||
Timer? _timer;
|
||||
bool _stopped = false;
|
||||
|
||||
/// 꺼진 까닭 (사용자가 끔이면 null)
|
||||
String? stopReason;
|
||||
|
||||
/// 접속 기록 (새것이 앞)
|
||||
final log = <WifiAccess>[];
|
||||
final _sessions = <String>{};
|
||||
final _fails = <String, (int, DateTime)>{};
|
||||
int _totalFails = 0;
|
||||
|
||||
/// 이 기기의 주소들 (사설망 먼저). 화면 · QR 에 쓴다
|
||||
List<String> addresses = const [];
|
||||
|
||||
WifiShare._(this.root, this._rootReal, this.code, this._servers, this._rng) : started = DateTime.now();
|
||||
|
||||
int get port => _servers.first.port;
|
||||
bool get running => !_stopped;
|
||||
|
||||
/// 접속 주소 (코드를 넣은 것 - QR 용)
|
||||
String urlWithCode(String host) => 'http://$host:$port/?code=$code';
|
||||
|
||||
/// 공유를 켠다. 이미 켜진 것이 있으면 먼저 끈다. [autoOff] 가 null 이면 저절로 끄지 않는다
|
||||
static Future<WifiShare> start(String root,
|
||||
{Duration? autoOff, int port = defaultPort, Random? random, bool loopbackOnly = false, bool tailscale = true}) async {
|
||||
await instance?.stop();
|
||||
final dir = Directory(root);
|
||||
if (!await dir.exists()) throw FileSystemException(tr('폴더가 없습니다'), root);
|
||||
final real = await dir.resolveSymbolicLinks();
|
||||
final rng = random ?? Random.secure();
|
||||
final code = List.generate(6, (_) => rng.nextInt(10)).join();
|
||||
final loop = loopbackOnly || debugLoopbackOnly;
|
||||
final addrs = debugAddresses ?? await localAddresses(tailscale: tailscale);
|
||||
// 보안 검토 3: 화면에 보이는 주소 (Wi-Fi · 유선 · Tailscale) 에만 연다 - 모바일 데이터 · 가상 어댑터 · 다른 VPN 에서는 열리지 않게
|
||||
if (!loop && addrs.isEmpty) throw SocketException(tr('Wi-Fi 나 유선 망에 연결되어 있지 않아 켤 수 없습니다'));
|
||||
final binds = loop ? ['127.0.0.1'] : addrs;
|
||||
final servers = <HttpServer>[];
|
||||
final shown = <String>[];
|
||||
try {
|
||||
for (final a in binds) {
|
||||
final at = InternetAddress(a);
|
||||
try {
|
||||
servers.add(await HttpServer.bind(at, servers.isEmpty ? port : servers.first.port));
|
||||
} on SocketException {
|
||||
if (servers.isNotEmpty) continue; // 같은 포트를 못 연 주소는 빼고 보인다
|
||||
servers.add(await HttpServer.bind(at, 0)); // 쓰는 중이면 빈 포트
|
||||
}
|
||||
shown.add(a);
|
||||
}
|
||||
} catch (_) {
|
||||
for (final x in servers) {
|
||||
unawaited(x.close(force: true));
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
final s = WifiShare._(root, real, code, servers, rng);
|
||||
for (final server in servers) {
|
||||
server.autoCompress = false;
|
||||
server.defaultResponseHeaders.clear();
|
||||
server.serverHeader = null;
|
||||
}
|
||||
s.addresses = loop ? addrs : shown;
|
||||
if (autoOff != null && autoOff > Duration.zero) {
|
||||
s.endsAt = s.started.add(autoOff);
|
||||
s._timer = Timer(autoOff, () => s.stop(reason: tr('정한 시간이 지나 저절로 껐습니다')));
|
||||
}
|
||||
for (final server in servers) {
|
||||
server.listen(s._handle, onError: (_) {});
|
||||
}
|
||||
current.value = s;
|
||||
return s;
|
||||
}
|
||||
|
||||
/// 공유를 끈다 (접속 중인 것도 끊는다)
|
||||
Future<void> stop({String? reason}) async {
|
||||
if (_stopped) return;
|
||||
_stopped = true;
|
||||
stopReason = reason;
|
||||
_timer?.cancel();
|
||||
_sessions.clear();
|
||||
if (identical(current.value, this)) current.value = null;
|
||||
for (final server in _servers) {
|
||||
try {
|
||||
await server.close(force: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 저절로 꺼질 시각을 [more] 만큼 늦춘다
|
||||
void extend(Duration more) {
|
||||
if (_stopped || endsAt == null) return;
|
||||
endsAt = endsAt!.add(more);
|
||||
_timer?.cancel();
|
||||
_timer = Timer(endsAt!.difference(DateTime.now()), () => stop(reason: tr('정한 시간이 지나 저절로 껐습니다')));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 이 기기의 IPv4 주소 (이 기기 자신 제외, 사설망 먼저). Wi-Fi · 유선 · Tailscale 만 -
|
||||
/// 모바일 데이터 (통신사 망) · 가상 어댑터 (WSL · Hyper-V · VirtualBox · VMware · Docker) · 그 밖의 VPN 은 뺀다 (보안 검토 3, 캡처에서 찾음)
|
||||
static Future<List<String>> localAddresses({bool tailscale = true}) async {
|
||||
try {
|
||||
final list = await NetworkInterface.list(type: InternetAddressType.IPv4, includeLoopback: false);
|
||||
final kinds = await netKindsOf();
|
||||
final all = [
|
||||
for (final i in list)
|
||||
for (final a in i.addresses)
|
||||
if (shareInterface(i.name, a.address, tailscale: tailscale, kinds: kinds)) a.address
|
||||
];
|
||||
int rank(String a) => a.startsWith('192.168.') ? 0 : a.startsWith('10.') ? 1 : a.startsWith('172.') ? 2 : 3;
|
||||
return (all.where(isLocalNetwork).toSet().toList()..sort((a, b) => rank(a).compareTo(rank(b))));
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// 공유를 열 연결인지 (연결 이름 [name] · 주소 [ip]). Tailscale (100.64/10) 은 연결 이름과 상관없이 연다 (Android 는 tun0)
|
||||
/// [tailscale] 이 거짓이면 Tailscale 주소는 열지 않는다 (설정 "Tailscale 에서도 열기")
|
||||
/// [kinds]: Android 가 알려 준 주소별 망 종류 (있으면 연결 이름보다 먼저 - Wi-Fi · 유선만, 모바일 데이터 · VPN 은 뺀다)
|
||||
static bool shareInterface(String name, String ip, {bool tailscale = true, Map<String, String>? kinds}) {
|
||||
final v = ip.split('.');
|
||||
final a = int.tryParse(v.first) ?? 0, b = v.length > 1 ? int.tryParse(v[1]) ?? 0 : 0;
|
||||
// Tailscale 은 VPN 이지만 설정대로 (통신사 CGNAT 100.x 는 모바일 데이터라 뺀다)
|
||||
if (a == 100 && b >= 64 && b <= 127) return tailscale && kinds?[ip] != 'cellular' && !isMobileInterfaceName(name);
|
||||
if (a == 169 && b == 254) return false; // 주소를 못 받은 연결
|
||||
return isLanAddress(ip, name, kinds);
|
||||
}
|
||||
|
||||
/// 사설망 · 링크 로컬 · Tailscale (100.64/10) · 이 기기 주소인지
|
||||
static bool isLocalNetwork(String ip) {
|
||||
final v = ip.split('.').map(int.tryParse).toList();
|
||||
if (v.length != 4 || v.any((x) => x == null)) return ip == '::1';
|
||||
final a = v[0]!, b = v[1]!;
|
||||
return a == 10 ||
|
||||
a == 127 ||
|
||||
(a == 172 && b >= 16 && b <= 31) ||
|
||||
(a == 192 && b == 168) ||
|
||||
(a == 169 && b == 254) ||
|
||||
(a == 100 && b >= 64 && b <= 127);
|
||||
}
|
||||
|
||||
/// [rel] (공유 폴더 기준, '/' 로 나눈 것) 의 실제 경로. 폴더 밖 · 링크 · 쓸 수 없는 이름이면 null.
|
||||
/// 보안 검토 10: 디스크는 비동기로 본다 (같은 망의 기기가 요청할 때마다 화면이 멈칫하지 않게)
|
||||
Future<String?> resolve(String rel) async {
|
||||
final segs = <String>[];
|
||||
for (final s in rel.split('/')) {
|
||||
if (s.isEmpty) continue;
|
||||
if (s == '.' || s == '..' || s.contains('\\') || s.contains(':') || s.contains('\u0000')) return null;
|
||||
segs.add(s);
|
||||
}
|
||||
var at = _rootReal;
|
||||
for (final s in segs) {
|
||||
at = p.join(at, s);
|
||||
// 링크 · junction 은 (가리키는 곳이 공유 폴더 안이어도) 따라가지 않는다
|
||||
if (await FileSystemEntity.type(at, followLinks: false) == FileSystemEntityType.link) return null;
|
||||
}
|
||||
if (await FileSystemEntity.type(at, followLinks: false) == FileSystemEntityType.notFound) return null;
|
||||
try {
|
||||
final real = await FileSystemEntity.isDirectory(at) ? await Directory(at).resolveSymbolicLinks() : await File(at).resolveSymbolicLinks();
|
||||
if (!p.equals(real, _rootReal) && !p.isWithin(_rootReal, real)) return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return at;
|
||||
}
|
||||
|
||||
/// [resolve] 가 null 일 때: 막은 것 (폴더 밖 · 링크 · 쓸 수 없는 이름) 인지, 그냥 없는 것인지
|
||||
Future<bool> _blocked(String rel) async {
|
||||
var at = _rootReal;
|
||||
for (final s in rel.split('/')) {
|
||||
if (s.isEmpty) continue;
|
||||
if (s == '.' || s == '..' || s.contains('\\') || s.contains(':') || s.contains('\u0000')) return true;
|
||||
at = p.join(at, s);
|
||||
final t = await FileSystemEntity.type(at, followLinks: false);
|
||||
if (t == FileSystemEntityType.link) return true;
|
||||
if (t == FileSystemEntityType.notFound) return false;
|
||||
}
|
||||
return true; // 있는데 밖을 가리킴
|
||||
}
|
||||
|
||||
Future<void> _handle(HttpRequest req) async {
|
||||
final res = req.response;
|
||||
final ip = req.connectionInfo?.remoteAddress.address ?? '?';
|
||||
var status = 200;
|
||||
var path = '/';
|
||||
try {
|
||||
res.headers
|
||||
..set('X-Content-Type-Options', 'nosniff')
|
||||
..set('Referrer-Policy', 'no-referrer')
|
||||
..set('Cache-Control', 'no-store')
|
||||
..set('X-Frame-Options', 'DENY');
|
||||
if (!isLocalNetwork(ip)) {
|
||||
status = 403;
|
||||
return await _text(res, 403, tr('같은 네트워크에서만 열 수 있습니다'));
|
||||
}
|
||||
if (req.method != 'GET' && req.method != 'HEAD') {
|
||||
status = 405;
|
||||
res.headers.set('Allow', 'GET, HEAD');
|
||||
return await _text(res, 405, tr('읽기 전용 공유입니다 (보기 · 받기만)'));
|
||||
}
|
||||
path = req.uri.path;
|
||||
// 코드 확인 (쿠키가 없으면 ?code= 로)
|
||||
if (!_hasSession(req)) {
|
||||
final given = req.uri.queryParameters['code'];
|
||||
final locked = _lockedUntil(ip);
|
||||
if (locked != null) {
|
||||
status = 429;
|
||||
res.headers.set('Retry-After', '${locked.difference(DateTime.now()).inSeconds + 1}');
|
||||
return await _page(res, 429, _loginHtml(tr('코드를 여러 번 틀려 잠시 막았습니다. 1분 뒤에 다시 하세요.')));
|
||||
}
|
||||
if (given == null || given.isEmpty) {
|
||||
status = 401;
|
||||
return await _page(res, 401, _loginHtml(null));
|
||||
}
|
||||
if (!_sameCode(given)) {
|
||||
status = 401;
|
||||
_fail(ip);
|
||||
return await _page(res, 401, _loginHtml(tr('코드가 맞지 않습니다')));
|
||||
}
|
||||
_fails.remove(ip);
|
||||
final token = base64Url.encode(List.generate(32, (_) => _rng.nextInt(256))).replaceAll('=', '');
|
||||
_sessions.add(token);
|
||||
res.headers.set('Set-Cookie', 'jjshare=$token; Path=/; HttpOnly; SameSite=Strict');
|
||||
// 코드가 주소 막대 · 기록에 남지 않게 코드를 뺀 주소로 다시
|
||||
final q = Map.of(req.uri.queryParameters)..remove('code');
|
||||
status = 303;
|
||||
res.statusCode = 303;
|
||||
res.headers.set('Location', q.isEmpty ? req.uri.path : '${req.uri.path}?${Uri(queryParameters: q).query}');
|
||||
await res.close();
|
||||
return;
|
||||
}
|
||||
final rel = Uri.decodeComponent(path);
|
||||
final target = await resolve(rel);
|
||||
if (target == null) {
|
||||
status = await _blocked(rel) ? 403 : 404;
|
||||
return await _text(res, status, status == 403 ? tr('공유한 폴더 밖이거나 링크라 열 수 없습니다') : tr('없는 항목입니다'));
|
||||
}
|
||||
if (await FileSystemEntity.isDirectory(target)) {
|
||||
return await _page(res, 200, await _listHtml(rel, target), head: req.method == 'HEAD');
|
||||
}
|
||||
status = await _file(req, res, target);
|
||||
} catch (_) {
|
||||
status = 500;
|
||||
try {
|
||||
await _text(res, 500, tr('읽지 못했습니다'));
|
||||
} catch (_) {}
|
||||
} finally {
|
||||
_record(ip, path, status);
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasSession(HttpRequest req) {
|
||||
for (final c in req.cookies) {
|
||||
if (c.name == 'jjshare' && _sessions.contains(c.value)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 걸리는 시간이 맞는 자리 수에 따라 달라지지 않게 비교
|
||||
bool _sameCode(String given) {
|
||||
if (given.length != code.length) return false;
|
||||
var d = 0;
|
||||
for (var i = 0; i < code.length; i++) {
|
||||
d |= given.codeUnitAt(i) ^ code.codeUnitAt(i);
|
||||
}
|
||||
return d == 0;
|
||||
}
|
||||
|
||||
DateTime? _lockedUntil(String ip) {
|
||||
final f = _fails[ip];
|
||||
if (f == null || f.$1 < perClientFails) return null;
|
||||
final until = f.$2.add(lockout);
|
||||
if (DateTime.now().isAfter(until)) {
|
||||
_fails.remove(ip);
|
||||
return null;
|
||||
}
|
||||
return until;
|
||||
}
|
||||
|
||||
void _fail(String ip) {
|
||||
final f = _fails[ip];
|
||||
_fails[ip] = ((f?.$1 ?? 0) + 1, DateTime.now());
|
||||
_totalFails++;
|
||||
if (_totalFails >= totalFails) {
|
||||
unawaited(stop(reason: tr('코드를 너무 많이 틀려 공유를 껐습니다 (다른 기기가 코드를 맞히려 했을 수 있습니다)')));
|
||||
}
|
||||
}
|
||||
|
||||
/// 시험: 틀린 코드 하나를 센다 (막힌 기기에서 온 것처럼 서버를 거치지 않고)
|
||||
@visibleForTesting
|
||||
void debugFail(String ip) => _fail(ip);
|
||||
|
||||
void _record(String ip, String path, int status) {
|
||||
if (_stopped) return;
|
||||
log.insert(0, WifiAccess(DateTime.now(), ip, path, status));
|
||||
if (log.length > maxLog) log.removeRange(maxLog, log.length);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 파일 보내기 (Range 한 구간 지원 - 동영상 건너뛰기). 돌려주는 값: 상태 코드
|
||||
Future<int> _file(HttpRequest req, HttpResponse res, String path) async {
|
||||
final f = File(path);
|
||||
final len = await f.length();
|
||||
final name = p.basename(path);
|
||||
final ext = p.extension(name).toLowerCase().replaceFirst('.', '');
|
||||
final active = const {'html', 'htm', 'xhtml', 'svg', 'xml', 'js', 'mjs'}.contains(ext);
|
||||
final download = active || req.uri.queryParameters['dl'] == '1';
|
||||
res.headers
|
||||
..set('Content-Type', active ? 'application/octet-stream' : contentTypeOf(ext))
|
||||
..set('Content-Security-Policy', 'sandbox')
|
||||
..set('Accept-Ranges', 'bytes')
|
||||
..set('Content-Disposition', '${download ? 'attachment' : 'inline'}; filename*=UTF-8\'\'${Uri.encodeComponent(name)}');
|
||||
var start = 0, end = len - 1, status = 200;
|
||||
final range = req.headers.value('range');
|
||||
final m = range == null ? null : RegExp(r'^bytes=(\d*)-(\d*)$').firstMatch(range.trim());
|
||||
if (m != null && len > 0) {
|
||||
final a = int.tryParse(m[1]!), b = int.tryParse(m[2]!);
|
||||
if (a == null && b != null) {
|
||||
start = max(0, len - b);
|
||||
} else if (a != null) {
|
||||
start = a;
|
||||
if (b != null) end = min(b, len - 1);
|
||||
}
|
||||
if (start > end || start >= len) {
|
||||
res.statusCode = 416;
|
||||
res.headers.set('Content-Range', 'bytes */$len');
|
||||
await res.close();
|
||||
return 416;
|
||||
}
|
||||
status = 206;
|
||||
res.headers.set('Content-Range', 'bytes $start-$end/$len');
|
||||
}
|
||||
res.statusCode = status;
|
||||
res.contentLength = len == 0 ? 0 : end - start + 1;
|
||||
if (req.method == 'HEAD' || len == 0) {
|
||||
await res.close();
|
||||
return status;
|
||||
}
|
||||
await res.addStream(f.openRead(start, end + 1));
|
||||
await res.close();
|
||||
return status;
|
||||
}
|
||||
|
||||
static String contentTypeOf(String ext) => switch (ext) {
|
||||
'txt' || 'srt' || 'smi' || 'ass' || 'ssa' || 'vtt' || 'log' || 'md' || 'csv' || 'ini' || 'nfo' => 'text/plain; charset=utf-8',
|
||||
'json' => 'application/json',
|
||||
'pdf' => 'application/pdf',
|
||||
'jpg' || 'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'gif' => 'image/gif',
|
||||
'webp' => 'image/webp',
|
||||
'bmp' => 'image/bmp',
|
||||
'mp4' || 'm4v' => 'video/mp4',
|
||||
'webm' => 'video/webm',
|
||||
'mkv' => 'video/x-matroska',
|
||||
'mov' => 'video/quicktime',
|
||||
'mp3' => 'audio/mpeg',
|
||||
'm4a' || 'aac' => 'audio/aac',
|
||||
'ogg' || 'opus' => 'audio/ogg',
|
||||
'wav' => 'audio/wav',
|
||||
'flac' => 'audio/flac',
|
||||
'zip' => 'application/zip',
|
||||
_ => 'application/octet-stream',
|
||||
};
|
||||
|
||||
Future<void> _text(HttpResponse res, int status, String msg) async {
|
||||
res.statusCode = status;
|
||||
res.headers.set('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.write(msg);
|
||||
await res.close();
|
||||
}
|
||||
|
||||
Future<void> _page(HttpResponse res, int status, String html, {bool head = false}) async {
|
||||
res.statusCode = status;
|
||||
res.headers
|
||||
..set('Content-Type', 'text/html; charset=utf-8')
|
||||
// 앱이 만드는 화면에도 스크립트를 쓰지 않는다
|
||||
..set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; form-action 'self'");
|
||||
if (!head) res.write(html);
|
||||
await res.close();
|
||||
}
|
||||
|
||||
static String _esc(String s) =>
|
||||
s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
||||
|
||||
static String _href(String rel) => '/${rel.split('/').where((s) => s.isNotEmpty).map(Uri.encodeComponent).join('/')}';
|
||||
|
||||
static const _css = 'body{font-family:sans-serif;background:#16171c;color:#e8e8ea;margin:0;padding:16px;max-width:900px}'
|
||||
'a{color:#4fd1d9;text-decoration:none}h1{font-size:18px;word-break:break-all}'
|
||||
'table{width:100%;border-collapse:collapse}td{padding:8px 4px;border-bottom:1px solid #2a2b33;word-break:break-all}'
|
||||
'td.s{color:#9a9aa3;white-space:nowrap;text-align:right;font-size:13px}'
|
||||
'input{font-size:24px;letter-spacing:6px;width:9em;padding:8px;margin-right:8px}button{font-size:18px;padding:10px 16px}'
|
||||
'.e{color:#ff6b6b}.n{color:#9a9aa3;font-size:13px}';
|
||||
|
||||
String _loginHtml(String? error) => '<!doctype html><html><head><meta charset="utf-8">'
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1"><title>JJ_MKVMaker</title><style>$_css</style></head><body>'
|
||||
'<h1>${_esc(tr('JJ_MKVMaker Wi-Fi 공유'))}</h1>'
|
||||
'<p>${_esc(tr('공유한 기기의 화면에 보이는 6자리 코드를 넣으세요.'))}</p>'
|
||||
'${error == null ? '' : '<p class="e">${_esc(error)}</p>'}'
|
||||
'<form method="get" action="/"><input name="code" inputmode="numeric" autocomplete="off" maxlength="6" autofocus>'
|
||||
'<button type="submit">${_esc(tr('열기'))}</button></form></body></html>';
|
||||
|
||||
Future<String> _listHtml(String rel, String dir) async {
|
||||
final entries = <FileSystemEntity>[];
|
||||
final sizes = <String, int>{};
|
||||
try {
|
||||
await for (final e in Directory(dir).list(followLinks: false)) {
|
||||
final n = p.basename(e.path);
|
||||
if (e is Link || isAppTempName(n) || isPartialFile(n)) continue; // 링크는 보이지도 않게
|
||||
entries.add(e);
|
||||
if (e is File) {
|
||||
try {
|
||||
sizes[e.path] = await e.length();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
entries.sort((a, b) {
|
||||
final d = (a is Directory ? 0 : 1) - (b is Directory ? 0 : 1);
|
||||
return d != 0 ? d : p.basename(a.path).toLowerCase().compareTo(p.basename(b.path).toLowerCase());
|
||||
});
|
||||
final parts = rel.split('/').where((s) => s.isNotEmpty).toList();
|
||||
final title = parts.isEmpty ? p.basename(root) : '${p.basename(root)} / ${parts.join(' / ')}';
|
||||
final b = StringBuffer('<!doctype html><html><head><meta charset="utf-8">'
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1"><title>${_esc(title)}</title><style>$_css</style></head><body>'
|
||||
'<h1>${_esc(title)}</h1><p class="n">${_esc(tr('읽기 전용 · 보기와 받기만 됩니다'))}</p><table>');
|
||||
if (parts.isNotEmpty) {
|
||||
final up = _href(parts.sublist(0, parts.length - 1).join('/'));
|
||||
b.write('<tr><td><a href="${up == '/' ? '/' : '$up/'}">⬆ ${_esc(tr('상위 폴더'))}</a></td><td></td></tr>');
|
||||
}
|
||||
for (final e in entries) {
|
||||
final n = p.basename(e.path);
|
||||
final href = _href([...parts, n].join('/'));
|
||||
if (e is Directory) {
|
||||
b.write('<tr><td><a href="$href/">📁 ${_esc(n)}</a></td><td class="s"></td></tr>');
|
||||
} else {
|
||||
final size = sizes[e.path] ?? 0;
|
||||
b.write('<tr><td><a href="$href">${_esc(n)}</a></td>'
|
||||
'<td class="s">${_esc(formatSize(size))} · <a href="$href?dl=1">${_esc(tr('받기'))}</a></td></tr>');
|
||||
}
|
||||
}
|
||||
if (entries.isEmpty) b.write('<tr><td class="n">${_esc(tr('빈 폴더입니다'))}</td><td></td></tr>');
|
||||
b.write('</table></body></html>');
|
||||
return b.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/// 89: 화면에 보이는 접속 한 줄 (파일에는 남기지 않는다)
|
||||
class WifiAccess {
|
||||
final DateTime at;
|
||||
final String ip;
|
||||
final String path;
|
||||
final int status;
|
||||
const WifiAccess(this.at, this.ip, this.path, this.status);
|
||||
}
|
||||
|
|
@ -36,8 +36,6 @@ import 'app/settings.dart';
|
|||
import 'core/app_update.dart';
|
||||
import 'core/playlist.dart';
|
||||
import 'core/sftp.dart' show SftpRegistry;
|
||||
import 'core/ftp.dart' show FtpRegistry;
|
||||
import 'core/smb.dart' show SmbRemote;
|
||||
import 'platform/android/android_download_tools.dart';
|
||||
import 'platform/android/android_keep_alive.dart';
|
||||
import 'platform/android/android_shell.dart' show applyScreenOrientation;
|
||||
|
|
@ -474,8 +472,6 @@ Future<void> setupNetwork(AppController c, String dataDir, {bool second = false}
|
|||
HttpOverrides.global = JjHttpOverrides();
|
||||
// 88: SFTP 는 HTTP 가 아니라 직접 잇는다 - 같은 DNS 설정을 따르게 (집 안 이름 · 주소는 기기 DNS)
|
||||
SftpRegistry.connect = SftpRegistry.connectWithDns(g.resolver);
|
||||
FtpRegistry.resolve = FtpRegistry.resolveWithDns(g.resolver);
|
||||
SmbRemote.resolve = FtpRegistry.resolveWithDns(g.resolver);
|
||||
if (second) return;
|
||||
TorService.dataDir = dataDir;
|
||||
TorService.instance.log = c.note;
|
||||
|
|
|
|||
|
|
@ -83,8 +83,7 @@ class AndroidDownloadTools {
|
|||
}),
|
||||
_Deferred(DownloadKind.torrent, () async {
|
||||
final t = await ready();
|
||||
return Aria2Backend(aria2c: t.aria2c, environment: t.environment, extraArgs: ['--ca-certificate=${t.cert}'],
|
||||
stallAfter: () => Duration(seconds: s().torrentStallSeconds));
|
||||
return Aria2Backend(aria2c: t.aria2c, environment: t.environment, extraArgs: ['--ca-certificate=${t.cert}']);
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import '../../app/download_manager.dart';
|
|||
import '../../app/copy_center.dart';
|
||||
import '../../app/live_sync.dart';
|
||||
import '../../services/downloader.dart';
|
||||
import '../../core/wifi_share.dart';
|
||||
import '../../l10n/tr.dart';
|
||||
|
||||
/// Android: 다운로드 · MKV 만들기 · AI 자막이 진행 중이면 포그라운드 서비스 (MainActivity.kt · KeepAliveService.kt)
|
||||
|
|
@ -30,7 +29,6 @@ class AndroidKeepAlive {
|
|||
downloads.addListener(_changed);
|
||||
AiJobs.instance.addListener(_changed); // 123: AI 그림도 화면을 닫아도 계속
|
||||
i18n.addListener(_changed); // 57: 알림 글 · 채널 이름도 화면 언어로
|
||||
WifiShare.current.addListener(_changed); // 89: Wi-Fi 공유가 켜져 있는 동안 (화면을 닫아도 서버가 살아 있게)
|
||||
_changed();
|
||||
}
|
||||
|
||||
|
|
@ -102,8 +100,7 @@ class AndroidKeepAlive {
|
|||
syncing: live?.anyRunning ?? false,
|
||||
copies: copying,
|
||||
copyProgress: copies?.activeProgress,
|
||||
stopped: live?.problems.length ?? 0,
|
||||
wifiShare: WifiShare.instance != null);
|
||||
stopped: live?.problems.length ?? 0);
|
||||
if (ai.isNotEmpty) {
|
||||
// 진행 알림에 남은 장 수와 예상 시간 (발열 · 배터리 때문에 쉬어 가지는 않는다 - 사용자 결정)
|
||||
text = text == null ? ai : '$ai · $text';
|
||||
|
|
@ -130,7 +127,7 @@ class AndroidKeepAlive {
|
|||
/// 알림 글과 진행률 (0~100, 모르면 -1). 진행 중인 일이 없으면 글이 null.
|
||||
/// [syncs] 백그라운드로 지켜보는 실시간 동기화 수 ([syncing] 지금 맞추는 중)
|
||||
static (String?, int) status(String? job, bool busy, int downloads, double? downloadProgress,
|
||||
{int syncs = 0, bool syncing = false, int copies = 0, double? copyProgress, int stopped = 0, bool wifiShare = false}) {
|
||||
{int syncs = 0, bool syncing = false, int copies = 0, double? copyProgress, int stopped = 0}) {
|
||||
final parts = [
|
||||
// 68: 원본을 읽지 못해 멈춘 동기화 (화면을 보지 않아도 알 수 있게 맨 앞에)
|
||||
if (stopped > 0) trf('⚠ 동기화 멈춤 {0}개 · 원본 확인 필요', [stopped]),
|
||||
|
|
@ -140,8 +137,6 @@ class AndroidKeepAlive {
|
|||
copyProgress == null ? trf('복사 · 이동 {0}개', [copies]) : trf('복사 · 이동 {0}개 · {1}%', [copies, (copyProgress * 100).round()]),
|
||||
if (downloads > 0) trf('다운로드 {0}개{1}', [downloads, downloadProgress == null ? '' : ' · ${(downloadProgress * 100).round()}%']),
|
||||
if (syncs > 0) syncing ? trf('동기화 {0}개 · 맞추는 중', [syncs]) : trf('동기화 {0}개 지켜보는 중', [syncs]),
|
||||
// 89: 켜져 있음을 알림줄에도 (코드 · 주소는 알림에 넣지 않는다 - 잠금 화면에 보일 수 있음)
|
||||
if (wifiShare) tr('Wi-Fi 공유 켜짐'),
|
||||
];
|
||||
if (parts.isEmpty) return (null, -1);
|
||||
// 진행률 막대는 다운로드만 있을 때 (작업 진행률은 동영상마다 따로라 하나로 합치기 어렵다).
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart' as mk;
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../core/remote_stream.dart';
|
||||
import '../../core/secret_gate.dart';
|
||||
import '../../core/vfs.dart';
|
||||
import '../../services/media_player.dart';
|
||||
|
|
@ -135,9 +135,8 @@ class MediaKitFullPlayer implements MediaPlayer {
|
|||
/// WebDAV 는 받지 않고 바로 스트리밍 (주소 + 인증 헤더, 자체 서명 인증서면 확인 안 함)
|
||||
Future<mk.Media> _media(String f) async {
|
||||
final native = _p.platform;
|
||||
// sftp_next: SFTP · SMB 는 스트리밍 주소가 없어 이 기기 안의 다리 (127.0.0.1, Range → 부분 읽기) 로 받지 않고 튼다.
|
||||
// 그래서 같은 폴더의 다음 동영상으로도 넘어간다 (88 1차는 고른 파일 하나를 다 받아서 그것만 틀었다)
|
||||
if (noStreamUrl(f)) return mk.Media((await RemoteStreamBridge.get()).url(f));
|
||||
// 88: SFTP 는 스트리밍 주소가 없어 받아서 튼다 (보통은 playFiles 가 미리 받아 둔다 - 여기는 안전망)
|
||||
if (noStreamUrl(f)) return mk.Media(await vLocalCopy(f, Directory.systemTemp.path));
|
||||
final dav = isWebDav(f) ? davStream(f) : null;
|
||||
if (native is mk.NativePlayer) {
|
||||
_tlsDefault ??= await native.getProperty('tls-verify');
|
||||
|
|
|
|||
|
|
@ -28,12 +28,8 @@ class Aria2Backend implements DownloadBackend {
|
|||
final Map<String, String> environment;
|
||||
final List<String> extraArgs;
|
||||
|
||||
/// 마그넷 · 토렌트가 이 시간 동안 메타데이터 · 피어를 못 받으면 그 줄에 알린다 (환경 설정, 0 = 알리지 않음)
|
||||
final Duration Function() stallAfter;
|
||||
|
||||
Aria2Backend({String? aria2c, this.environment = const {}, this.extraArgs = const [], Duration Function()? stallAfter})
|
||||
: aria2c = aria2c ?? locateTool('aria2c'),
|
||||
stallAfter = stallAfter ?? (() => const Duration(seconds: 60));
|
||||
Aria2Backend({String? aria2c, this.environment = const {}, this.extraArgs = const []})
|
||||
: aria2c = aria2c ?? locateTool('aria2c');
|
||||
|
||||
@override
|
||||
DownloadKind get kind => DownloadKind.torrent;
|
||||
|
|
@ -73,9 +69,6 @@ class Aria2Backend implements DownloadBackend {
|
|||
'--bt-save-metadata=false', '--follow-torrent=mem',
|
||||
'--console-log-level=warn', '--summary-interval=0',
|
||||
'--file-allocation=none', '--auto-file-renaming=false',
|
||||
// 28 확인 (10/20): 인터넷 IPv6 가 없는데 (Tailscale fd7a:… 같은 ULA 뿐) IPv6 가 있다고 보이면 aria2 가 트래커 · 피어에
|
||||
// IPv6 로 먼저 붙다 실패하고 IPv4 로 다시 시도하지 않아 마그넷이 시작되지 않았다 - 그럴 때만 IPv6 를 끈다
|
||||
if (!await _internetIpv6()) '--disable-ipv6=true',
|
||||
...extraArgs,
|
||||
...dns,
|
||||
], environment: environment.isEmpty ? null : environment);
|
||||
|
|
@ -153,32 +146,6 @@ class Aria2Backend implements DownloadBackend {
|
|||
changed();
|
||||
}
|
||||
|
||||
/// 28 확인: 이 기기의 인터넷 IPv6 · IPv4 (모르면 있다고 본다 - aria2 기본대로)
|
||||
static Future<bool> _internetIpv6() async {
|
||||
try {
|
||||
final l = await NetworkInterface.list(type: InternetAddressType.IPv6, includeLinkLocal: false);
|
||||
return hasInternetIpv6(l.expand((i) => i.addresses));
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> _internetIpv4() async {
|
||||
try {
|
||||
final l = await NetworkInterface.list(type: InternetAddressType.IPv4, includeLinkLocal: false);
|
||||
return hasInternetIpv4(l.expand((i) => i.addresses));
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 멈춘 마그넷 · 토렌트의 이유 후보 (화면 글은 downloads_page 의 [stallReasonText])
|
||||
static Future<List<String>> _stallReasons() async => [
|
||||
if (!await _internetIpv4()) 'ipv6only',
|
||||
'firewall',
|
||||
if (NetGuard.instance.forcing) 'dns',
|
||||
];
|
||||
|
||||
Future<void> _refresh() async {
|
||||
for (final (t, changed) in _tasks.values.toList()) {
|
||||
final gid = t.extra['gid'] as String?;
|
||||
|
|
@ -187,7 +154,7 @@ class Aria2Backend implements DownloadBackend {
|
|||
final s = await _call('aria2.tellStatus', [
|
||||
gid,
|
||||
['status', 'totalLength', 'completedLength', 'downloadSpeed', 'followedBy',
|
||||
'bittorrent', 'files', 'errorMessage', 'connections'],
|
||||
'bittorrent', 'files', 'errorMessage'],
|
||||
]) as Map;
|
||||
// 마그넷: 메타데이터를 받은 뒤 실제 다운로드로 넘어감
|
||||
final followed = s['followedBy'] as List?;
|
||||
|
|
@ -213,26 +180,6 @@ class Aria2Backend implements DownloadBackend {
|
|||
..eta = speed > 0 && total > done ? _eta((total - done) ~/ speed) : '';
|
||||
// 35: aria2 가 순서를 기다리게 한 것은 "받는 중" 이 아니라 "대기 중" 으로
|
||||
t.extra['waiting'] = s['status'] == 'waiting';
|
||||
// 관리자 지시 (10/20): 마그넷 · 토렌트가 메타데이터나 피어를 정한 시간 동안 못 받으면 그 줄에 알린다
|
||||
// (예전에는 아무 표시 없이 "받는 중" 이라 왜 안 되는지 알 수 없었다). 받은 것이 생기면 지운다
|
||||
final torrent = s['bittorrent'] != null || t.source.toLowerCase().startsWith('magnet:');
|
||||
final noInfo = ((s['bittorrent'] as Map?)?['info']) == null;
|
||||
final conns = int.tryParse('${s['connections']}') ?? 0;
|
||||
final after = stallAfter();
|
||||
if (torrent && s['status'] == 'active' && done == 0 && (noInfo || conns == 0) && after > Duration.zero) {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final since = (t.extra['stallSince'] as int?) ?? now;
|
||||
t.extra['stallSince'] = since;
|
||||
if (now - since >= after.inMilliseconds && t.extra['stalled'] != true) {
|
||||
t.extra['stalled'] = true;
|
||||
t.extra['stallReasons'] = await _stallReasons();
|
||||
}
|
||||
} else {
|
||||
t.extra
|
||||
..remove('stallSince')
|
||||
..remove('stalled')
|
||||
..remove('stallReasons');
|
||||
}
|
||||
switch (s['status']) {
|
||||
case 'complete':
|
||||
t
|
||||
|
|
@ -319,23 +266,3 @@ class Aria2Backend implements DownloadBackend {
|
|||
_client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// 28 확인 (10/20): 인터넷에 닿는 IPv6 (전역 2000::/3) 가 있는지. ULA (fc00::/7 - Tailscale fd7a:115c:a1e0:: 포함) ·
|
||||
/// 링크 로컬 (fe80::) · Teredo (2001:0::/32) · 6to4 (2002::/16) 만 있으면 없음
|
||||
bool hasInternetIpv6(Iterable<InternetAddress> addrs) => addrs.any((a) {
|
||||
if (a.type != InternetAddressType.IPv6) return false;
|
||||
final b = a.rawAddress;
|
||||
if ((b[0] & 0xE0) != 0x20) return false;
|
||||
if (b[0] == 0x20 && b[1] == 0x01 && b[2] == 0 && b[3] == 0) return false;
|
||||
if (b[0] == 0x20 && b[1] == 0x02) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
/// 인터넷 IPv4 로 보이는 주소가 있는지 (127/8 · 169.254/16 · Tailscale 100.64/10 은 빼고 - 사설 주소는 공유기 뒤라 넣는다)
|
||||
bool hasInternetIpv4(Iterable<InternetAddress> addrs) => addrs.any((a) {
|
||||
if (a.type != InternetAddressType.IPv4) return false;
|
||||
final b = a.rawAddress;
|
||||
if (b[0] == 127 || (b[0] == 169 && b[1] == 254)) return false;
|
||||
if (b[0] == 100 && (b[1] & 0xC0) == 64) return false;
|
||||
return true;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,10 +9,6 @@ import '../../services/storage_service.dart';
|
|||
import '../../l10n/tr.dart';
|
||||
|
||||
class DesktopStorageService implements StorageService {
|
||||
/// [tempDirectory] 의 기준 폴더 (null = 시스템 임시 폴더). 시험이 파일마다 따로 쓰고 끝에 지우게 한다
|
||||
/// (test/flutter_test_config.dart - 예전에는 시험이 %TEMP%\jj_mkvmaker 를 남겼다, 관리자 지시 10/20)
|
||||
static String? tempRoot;
|
||||
|
||||
@override
|
||||
Future<List<String>> pickVideos() async {
|
||||
final files = await FilePicker.pickFiles(
|
||||
|
|
@ -112,7 +108,7 @@ class DesktopStorageService implements StorageService {
|
|||
|
||||
@override
|
||||
Future<String> tempDirectory() async {
|
||||
final dir = Directory(p.join(tempRoot ?? Directory.systemTemp.path, 'jj_mkvmaker'));
|
||||
final dir = Directory(p.join(Directory.systemTemp.path, 'jj_mkvmaker'));
|
||||
await dir.create(recursive: true);
|
||||
return dir.path;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,19 +23,12 @@ class GitHubUpdater implements Updater {
|
|||
..userAgent = 'JJ_MKVMaker-updater'
|
||||
..connectionTimeout = const Duration(seconds: 20);
|
||||
|
||||
/// 받은 업데이트 · 교체 스크립트 · 기록을 둘 곳 (null = 시스템 임시 폴더). 시험은 시험 폴더를 준다
|
||||
/// (예전에는 시험이 %TEMP% 에 jj_mkvmaker_update_1.9.0 · .ps1 · .log 를 남겼다, 관리자 지시 10/20)
|
||||
final String? tempRoot;
|
||||
|
||||
GitHubUpdater({
|
||||
this.apiBase = 'https://api.github.com',
|
||||
String? appDir,
|
||||
this.versionOverride,
|
||||
this.tempRoot,
|
||||
}) : appDir = appDir ?? AppPaths.root; // Lib 구조면 배포 폴더 맨 위
|
||||
|
||||
String get _temp => tempRoot ?? Directory.systemTemp.path;
|
||||
|
||||
@override
|
||||
bool get installsInPlace => false;
|
||||
|
||||
|
|
@ -88,7 +81,7 @@ class GitHubUpdater implements Updater {
|
|||
Future<String> download(ReleaseInfo r, void Function(double progress) onProgress) async {
|
||||
final url = r.zipUrl;
|
||||
if (url == null) throw UpdateException(tr('이 버전에는 Windows 용 zip 이 없습니다.'));
|
||||
final work = Directory(p.join(_temp, 'jj_mkvmaker_update_${r.version}'));
|
||||
final work = Directory(p.join(Directory.systemTemp.path, 'jj_mkvmaker_update_${r.version}'));
|
||||
if (await work.exists()) await work.delete(recursive: true);
|
||||
await work.create(recursive: true);
|
||||
final zip = File(p.join(work.path, r.zipName ?? 'update.zip'));
|
||||
|
|
@ -141,7 +134,7 @@ param([int]$ParentPid, [string]$Source, [string]$Target, [string]$Exe, [switch]$
|
|||
$ErrorActionPreference = 'Continue'
|
||||
try { Wait-Process -Id $ParentPid -Timeout 90 -ErrorAction Stop } catch {}
|
||||
Start-Sleep -Milliseconds 800
|
||||
$log = Join-Path (Split-Path -Parent $PSCommandPath) 'jj_mkvmaker_update.log'
|
||||
$log = Join-Path $env:TEMP 'jj_mkvmaker_update.log'
|
||||
# keep downloads, models and user data
|
||||
$lib = Join-Path $Source 'Lib'
|
||||
if (Test-Path $lib) {
|
||||
|
|
@ -161,7 +154,7 @@ exit 0
|
|||
|
||||
/// 스크립트 실행. [waitPid] 가 끝나기를 기다렸다가 교체한다.
|
||||
Future<Process> runInstallScript(String source, {required int waitPid, bool restart = true}) async {
|
||||
final ps1 = File(p.join(_temp, 'jj_mkvmaker_update.ps1'));
|
||||
final ps1 = File(p.join(Directory.systemTemp.path, 'jj_mkvmaker_update.ps1'));
|
||||
await ps1.writeAsString(script);
|
||||
return Process.start(
|
||||
'powershell.exe',
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class DownloadTask {
|
|||
final Map<String, Object?> extra = {};
|
||||
|
||||
DownloadTask({required this.id, required this.kind, required this.source, required this.dir})
|
||||
: title = magnetName(source) ?? source; // 마그넷은 메타데이터 전에도 dn 이름으로 (받으면 토렌트 이름으로 바뀜)
|
||||
: title = source;
|
||||
|
||||
bool get unfinished =>
|
||||
state == DownloadState.queued || state == DownloadState.downloading || state == DownloadState.paused;
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ class PlatformServices {
|
|||
fallbackCookieArgs: () => ytDlpCookieArgs(
|
||||
browser: internalBrowserCookies, internalCookieFile: s().internalCookieFile),
|
||||
),
|
||||
Aria2Backend(stallAfter: () => Duration(seconds: s().torrentStallSeconds)),
|
||||
Aria2Backend(),
|
||||
],
|
||||
shell: DesktopShell(),
|
||||
updater: GitHubUpdater(),
|
||||
|
|
|
|||
|
|
@ -316,10 +316,6 @@ Future<String?> createZipFlow(BuildContext context, AppController c, List<String
|
|||
text: sources.length == 1 ? p.basenameWithoutExtension(sources.first) : p.basename(dir.endsWith(p.separator) ? p.dirname(dir) : dir));
|
||||
var level = c.settings.zipLevel;
|
||||
var where = dir;
|
||||
// zip_aes: 비밀번호 (비우면 보통 ZIP). 기억하지 않고, 기록에 남기지 않는다
|
||||
final pass = TextEditingController();
|
||||
final pass2 = TextEditingController();
|
||||
var showPass = false;
|
||||
final choices = <(String, String)>[(dir, tr('지금 폴더')), if (otherDir != null && !isDav(otherDir)) (otherDir, tr('다른 창 폴더'))];
|
||||
String target() {
|
||||
var n = name.text.trim();
|
||||
|
|
@ -353,79 +349,22 @@ Future<String?> createZipFlow(BuildContext context, AppController c, List<String
|
|||
_placeChoice(ctx, choices, where, (d) => set(() => where = d)),
|
||||
Text(trf('만들 파일: {0}', [p.basename(target())]), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
Text(tr('링크 · junction 은 넣지 않습니다'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: pass,
|
||||
obscureText: !showPass,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr('비밀번호 (선택)'),
|
||||
helperText: tr('넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP'),
|
||||
isDense: true,
|
||||
suffixIcon: IconButton(
|
||||
tooltip: showPass ? tr('비밀번호 숨기기') : tr('비밀번호 보기'),
|
||||
icon: Icon(showPass ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 18),
|
||||
onPressed: () => set(() => showPass = !showPass),
|
||||
),
|
||||
),
|
||||
onChanged: (_) => set(() {}),
|
||||
),
|
||||
if (pass.text.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
controller: pass2,
|
||||
obscureText: !showPass,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr('비밀번호 다시 넣기'),
|
||||
isDense: true,
|
||||
errorText: pass2.text.isNotEmpty && pass2.text != pass.text ? tr('두 비밀번호가 다릅니다') : null,
|
||||
),
|
||||
onChanged: (_) => set(() {}),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(tr('비밀번호를 잊으면 이 ZIP 을 풀 수 없습니다 (앱이 기억하지 않습니다). 파일 이름 목록은 암호화되지 않습니다.'),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
// zip_aes: 7-Zip 등 다른 프로그램은 영문 밖 글자 ZIP 비밀번호를 받지 않는다 (10/17 시험) - 막지는 않고 알린다
|
||||
if (pass.text.codeUnits.any((c) => c > 0x7e))
|
||||
Text(tr('한글 등 영문 밖 글자가 든 비밀번호는 이 앱에서는 풀리지만 7-Zip 등 다른 프로그램에서는 풀리지 않을 수 있습니다. 다른 기기 · 프로그램과 나눌 ZIP 은 영문 · 숫자 · 기호 비밀번호를 쓰세요.'),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.danger)),
|
||||
],
|
||||
]),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||||
FilledButton(
|
||||
onPressed: pass.text.isNotEmpty && pass.text != pass2.text ? null : () => Navigator.pop(ctx, true),
|
||||
child: Text(pass.text.isEmpty ? tr('묶기') : tr('암호로 묶기')),
|
||||
),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('묶기'))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
final zip = target();
|
||||
final password = pass.text;
|
||||
name.dispose();
|
||||
pass.dispose();
|
||||
pass2.dispose();
|
||||
if (go != true || !context.mounted) return null;
|
||||
if (level != c.settings.zipLevel) await c.updateSettings((x) => x.zipLevel = level);
|
||||
if (!context.mounted) return null;
|
||||
// zip_aes: 비밀번호 ZIP 은 동봉 7-Zip 으로 (흘려 가며 AES-256 - 큰 동영상도 메모리에 다 올리지 않게)
|
||||
final exe = password.isEmpty ? null : await bundledSevenZip();
|
||||
if (!context.mounted) return null;
|
||||
if (password.isNotEmpty && exe == null) {
|
||||
say(tr('비밀번호 ZIP 을 만들 7-Zip 이 이 판에 없습니다. 비밀번호 없이 묶거나 앱을 다시 설치해 주세요.'));
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final (n, links) = await _withProgress(
|
||||
context,
|
||||
password.isEmpty ? trf('ZIP 으로 묶는 중 · {0}', [p.basename(zip)]) : trf('암호 ZIP 으로 묶는 중 · {0}', [p.basename(zip)]),
|
||||
(set, cancelled) => exe == null
|
||||
? createZip(sources, zip, level: level, cancelled: cancelled, onProgress: (d, t) => set(t == 0 ? null : d / t))
|
||||
: createZipAes(exe, sources, zip, password: password, level: level, cancelled: cancelled, onProgress: set));
|
||||
final (n, links) = await _withProgress(context, trf('ZIP 으로 묶는 중 · {0}', [p.basename(zip)]),
|
||||
(set, cancelled) => createZip(sources, zip, level: level, cancelled: cancelled, onProgress: (d, t) => set(t == 0 ? null : d / t)));
|
||||
say([trf('{0}개를 묶었습니다: {1}', [n, p.basename(zip)]), if (links > 0) trf('링크 {0}개는 넣지 않음', [links])].join(' · '));
|
||||
return zip;
|
||||
} on ArchiveCancelled {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../core/batch_rename.dart';
|
||||
import '../core/photo_date.dart';
|
||||
import '../core/vfs.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import 'theme.dart';
|
||||
|
|
@ -10,41 +9,31 @@ import 'theme.dart';
|
|||
/// 78: 일괄 이름 바꾸기 창 - 규칙을 넣으면 바로 미리 보기 (원래 → 새 이름, 문제는 빨갛게), [바꾸기] 뒤 결과.
|
||||
/// 돌려주는 값: 바꾼 것 (원래 경로, 새 경로) - 탐색기가 [되돌리기] 에 쓴다. 취소하면 null.
|
||||
/// 실패하면 이미 바꾼 것은 되돌리고 창에 이유를 보인다.
|
||||
/// X-plore 비교 i: [initial] 은 마지막에 쓴 규칙 (창이 그 값으로 열림), [onRun] 은 바꾸기를 누를 때의 규칙 (기억하려고)
|
||||
Future<List<(String, String)>?> showBatchRename(BuildContext context, List<String> paths,
|
||||
{required bool Function(String) isDir, RenameRule initial = const RenameRule(), void Function(RenameRule rule)? onRun}) =>
|
||||
Future<List<(String, String)>?> showBatchRename(BuildContext context, List<String> paths, {required bool Function(String) isDir}) =>
|
||||
showDialog<List<(String, String)>>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => _BatchRenameDialog(paths: paths, isDir: isDir, initial: initial, onRun: onRun),
|
||||
builder: (_) => _BatchRenameDialog(paths: paths, isDir: isDir),
|
||||
);
|
||||
|
||||
class _BatchRenameDialog extends StatefulWidget {
|
||||
final List<String> paths;
|
||||
final bool Function(String) isDir;
|
||||
final RenameRule initial;
|
||||
final void Function(RenameRule rule)? onRun;
|
||||
const _BatchRenameDialog({required this.paths, required this.isDir, required this.initial, this.onRun});
|
||||
const _BatchRenameDialog({required this.paths, required this.isDir});
|
||||
|
||||
@override
|
||||
State<_BatchRenameDialog> createState() => _BatchRenameDialogState();
|
||||
}
|
||||
|
||||
class _BatchRenameDialogState extends State<_BatchRenameDialog> {
|
||||
late final _template = TextEditingController(text: widget.initial.template);
|
||||
late final _start = TextEditingController(text: '${widget.initial.start}');
|
||||
late final _step = TextEditingController(text: '${widget.initial.step}');
|
||||
late final _digits = TextEditingController(text: '${widget.initial.digits}');
|
||||
late final _find = TextEditingController(text: widget.initial.find);
|
||||
late final _replace = TextEditingController(text: widget.initial.replace);
|
||||
late bool _ignoreCase = widget.initial.ignoreCase;
|
||||
late String _case = widget.initial.caseMode;
|
||||
late String _dateSource = widget.initial.dateSource;
|
||||
|
||||
/// X-plore 비교 i: 항목마다 바뀐 때 (목록에서) · 찍은 때 (사진 Exif, 틀에 [D] · [T] 가 있을 때 읽음)
|
||||
final _modified = <String, DateTime>{};
|
||||
final _taken = <String, DateTime?>{};
|
||||
bool _takenLoading = false;
|
||||
final _template = TextEditingController(text: '[N]');
|
||||
final _start = TextEditingController(text: '1');
|
||||
final _step = TextEditingController(text: '1');
|
||||
final _digits = TextEditingController(text: '2');
|
||||
final _find = TextEditingController();
|
||||
final _replace = TextEditingController();
|
||||
bool _ignoreCase = true;
|
||||
String _case = 'keep';
|
||||
|
||||
/// 폴더마다 지금 있는 이름 (읽는 중이면 비어 있음)
|
||||
final _siblings = <String, Set<String>>{};
|
||||
|
|
@ -61,11 +50,7 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
|
|||
Future<void> _loadSiblings() async {
|
||||
for (final dir in {for (final x in widget.paths) vDirname(x)}) {
|
||||
try {
|
||||
final list = await vList(dir);
|
||||
_siblings[dir] = {for (final e in list) vBasename(e.path)};
|
||||
for (final e in list) {
|
||||
if (widget.paths.contains(e.path)) _modified[e.path] = e.modified;
|
||||
}
|
||||
_siblings[dir] = {for (final e in await vList(dir)) vBasename(e.path)};
|
||||
} catch (_) {
|
||||
_siblings[dir] = {};
|
||||
}
|
||||
|
|
@ -73,20 +58,6 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
|
|||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
/// 사진의 찍은 때를 한 번만 읽는다 (틀에 [D] · [T] 를 넣고 "찍은 때" 일 때)
|
||||
Future<void> _loadTaken() async {
|
||||
if (_takenLoading || _taken.isNotEmpty) return;
|
||||
_takenLoading = true;
|
||||
for (final x in widget.paths) {
|
||||
_taken[x] = await photoTakenAt(x);
|
||||
}
|
||||
if (mounted) setState(() => _takenLoading = false);
|
||||
}
|
||||
|
||||
Map<String, DateTime> get _dates => {
|
||||
for (final x in widget.paths) x: ?((_dateSource == 'photo' ? _taken[x] : null) ?? _modified[x]),
|
||||
};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final x in [_template, _start, _step, _digits, _find, _replace]) {
|
||||
|
|
@ -104,7 +75,6 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
|
|||
replace: _replace.text,
|
||||
ignoreCase: _ignoreCase,
|
||||
caseMode: _case,
|
||||
dateSource: _dateSource,
|
||||
);
|
||||
|
||||
void _insert(String tag) {
|
||||
|
|
@ -122,7 +92,6 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
|
|||
_running = true;
|
||||
_error = null;
|
||||
});
|
||||
widget.onRun?.call(_rule);
|
||||
try {
|
||||
final done = await runBatchRename([for (final r in rows) (r.path, r.newName)]);
|
||||
if (mounted) Navigator.pop(context, done);
|
||||
|
|
@ -158,11 +127,10 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rule = _rule;
|
||||
if (rule.usesDate && _dateSource == 'photo') _loadTaken();
|
||||
final rows = planRename(widget.paths, rule, isDir: widget.isDir, siblings: _siblings, dates: _dates);
|
||||
final rows = planRename(widget.paths, rule, isDir: widget.isDir, siblings: _siblings);
|
||||
final changed = rows.where((r) => r.changed).length;
|
||||
final bad = rows.where((r) => r.problem != null).length;
|
||||
final canRun = !_loading && !_takenLoading && !_running && bad == 0 && changed > 0;
|
||||
final canRun = !_loading && !_running && bad == 0 && changed > 0;
|
||||
return AlertDialog(
|
||||
title: Text(trf('이름 일괄 바꾸기 · {0}개', [widget.paths.length])),
|
||||
// 낮은 화면 · 큰 글자에서는 창 안이 스크롤 (253 시험: 아래가 넘쳤다). 폭은 고정이라 미리 보기 목록은 그대로 게으르게
|
||||
|
|
@ -179,8 +147,8 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
|
|||
decoration: InputDecoration(
|
||||
// 폭 412 에서 긴 이름이 잘려 (일본어) 이름은 짧게, 설명은 아래 줄에
|
||||
labelText: tr('이름 틀'),
|
||||
helperText: tr('[N] 원래 이름 · [C] 번호 · [D] 날짜 · [T] 시각 · 확장자는 그대로 예: Show S01E[C]'),
|
||||
helperMaxLines: 4,
|
||||
helperText: tr('[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]'),
|
||||
helperMaxLines: 3,
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
|
|
@ -191,30 +159,6 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
|
|||
const SizedBox(width: 4),
|
||||
OutlinedButton(onPressed: _running ? null : () => _insert('[C]'), child: const Text('[C]')),
|
||||
]),
|
||||
// X-plore 비교 i: 날짜 · 시각 넣기와 어느 날짜를 쓸지
|
||||
Row(children: [
|
||||
OutlinedButton(onPressed: _running ? null : () => _insert('[D]'), child: const Text('[D]')),
|
||||
const SizedBox(width: 4),
|
||||
OutlinedButton(onPressed: _running ? null : () => _insert('[T]'), child: const Text('[T]')),
|
||||
const SizedBox(width: 8),
|
||||
// 좁은 화면 (412) 에서 긴 항목이 창을 넘었다 - 남은 폭 안에서 줄임
|
||||
Expanded(
|
||||
child: DropdownButton<String>(
|
||||
isExpanded: true,
|
||||
value: _dateSource,
|
||||
// 412 캡처 (10/18): 고른 뒤 상자에는 짧게 (펼친 목록은 긴 설명 그대로)
|
||||
selectedItemBuilder: (_) => [
|
||||
Text(tr('날짜: 찍은 때'), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
Text(tr('날짜: 바뀐 때'), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
items: [
|
||||
DropdownMenuItem(value: 'photo', child: Text(tr('날짜: 찍은 때 (사진, 없으면 바뀐 때)'), overflow: TextOverflow.ellipsis)),
|
||||
DropdownMenuItem(value: 'modified', child: Text(tr('날짜: 바뀐 때'), overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
onChanged: _running ? null : (v) => setState(() => _dateSource = v!),
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(spacing: 10, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.end, children: [
|
||||
_num(_start, tr('번호 시작')),
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// 204 (사용자 지적 10/11): Android 브라우저에서 화면 아래를 위로 밀면 아래에서 검은 화면이 올라왔다.
|
||||
/// 제스처 내비 · 삼성 태블릿의 작업 표시줄은 아래에서 밀면 잠깐 나타나고, 그동안 아래 시스템 막대 높이 (viewPadding) 가 커진다.
|
||||
/// 앱 맨 위의 SafeArea 가 그만큼 앱 전체를 줄여 그 자리에 어두운 바탕이 보이고, 텍스처로 그리는 웹 뷰는
|
||||
/// 새 크기로 다시 그릴 때까지 늦어 더 검게 보였다.
|
||||
///
|
||||
/// 이 위젯 안의 [child] (웹 뷰) 는 그렇게 잠깐 커진 아래 막대 때문에는 크기를 바꾸지 않는다: 원래 크기 그대로 두고
|
||||
/// 줄어든 만큼 아래로 넘쳐 (자르지 않고) 그 띠 위에 그린다. 막대가 들어가면 그대로 원래 자리.
|
||||
/// - 키보드가 열리면 (viewInsets) 지금처럼 줄인다 - 입력 칸이 키보드에 가리지 않게.
|
||||
/// - 커진 막대가 [settle] 넘게 그대로면 (작업 표시줄을 고정하는 등) 그 크기를 새 기준으로 받아들인다.
|
||||
/// - 폭이 바뀌면 (회전 · 창 크기) 기준을 다시 잡는다.
|
||||
class BottomInsetHold extends StatefulWidget {
|
||||
final Widget child;
|
||||
final Duration settle;
|
||||
const BottomInsetHold({super.key, required this.child, this.settle = const Duration(seconds: 3)});
|
||||
|
||||
@override
|
||||
State<BottomInsetHold> createState() => _BottomInsetHoldState();
|
||||
}
|
||||
|
||||
class _BottomInsetHoldState extends State<BottomInsetHold> {
|
||||
/// 이 폭에서 기준으로 삼는 아래 시스템 막대 높이 (논리 픽셀)
|
||||
double? _base;
|
||||
double _width = -1;
|
||||
Timer? _accept;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_accept?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) {
|
||||
// 앱 위 SafeArea 가 MediaQuery 의 여백을 지우므로 창 값을 직접 본다. 막대가 바뀌면 SafeArea 가 크기를 바꿔
|
||||
// 이 builder 가 다시 불리므로 여기서 읽는다 (밖에서 읽으면 예전 값)
|
||||
final view = View.of(context);
|
||||
final dpr = view.devicePixelRatio;
|
||||
final pad = view.viewPadding.bottom / dpr;
|
||||
final keyboard = view.viewInsets.bottom / dpr;
|
||||
if (box.maxWidth != _width) {
|
||||
_width = box.maxWidth;
|
||||
_base = pad;
|
||||
}
|
||||
if (pad <= _base!) {
|
||||
_base = pad;
|
||||
_accept?.cancel();
|
||||
_accept = null;
|
||||
}
|
||||
final extra = keyboard > 0 ? 0.0 : pad - _base!;
|
||||
if (extra <= 0 || !box.hasBoundedHeight) return widget.child;
|
||||
// 오래 그대로면 새 기준으로 (한 번 다시 배치)
|
||||
_accept ??= Timer(widget.settle, () {
|
||||
if (!mounted) return;
|
||||
setState(() => _base = View.of(context).viewPadding.bottom / View.of(context).devicePixelRatio);
|
||||
_accept = null;
|
||||
});
|
||||
final h = box.maxHeight + extra;
|
||||
return OverflowBox(alignment: Alignment.topCenter, minHeight: h, maxHeight: h, child: widget.child);
|
||||
});
|
||||
}
|
||||
|
|
@ -33,7 +33,6 @@ import 'key_hint.dart';
|
|||
import 'settings_page.dart';
|
||||
import 'tor_page.dart';
|
||||
import 'theme.dart';
|
||||
import 'bottom_inset_hold.dart';
|
||||
import 'work_panel.dart';
|
||||
import '../l10n/tr.dart';
|
||||
|
||||
|
|
@ -1013,9 +1012,6 @@ class _SplitHandle extends StatelessWidget {
|
|||
|
||||
// ───────── 앱 안 Edge 웹뷰 ─────────
|
||||
|
||||
/// 204: Android 만 (Windows 창에는 아래 시스템 막대가 없음)
|
||||
Widget _holdBottom(Widget webView) => Platform.isAndroid ? BottomInsetHold(child: webView) : webView;
|
||||
|
||||
/// Edge · Android 웹뷰 설정 (처음 만들 때와 JavaScript 켜기 · 끄기에 같이 쓴다)
|
||||
InAppWebViewSettings _webSettings({required bool javaScript}) => _JjWebSettings(javaScript: javaScript);
|
||||
|
||||
|
|
@ -1129,8 +1125,7 @@ class _EdgeViewState extends State<_EdgeView> with WebProxyVisibility {
|
|||
} catch (_) {}
|
||||
}
|
||||
|
||||
// 204: 아래 시스템 막대가 잠깐 커질 때 (제스처 · 작업 표시줄) 웹 뷰 크기를 바꾸지 않게 (검은 띠가 올라오던 것)
|
||||
return _holdBottom(InAppWebView(
|
||||
return InAppWebView(
|
||||
keepAlive: _keepAlive,
|
||||
webViewEnvironment: snap.data,
|
||||
initialUrlRequest: URLRequest(url: WebUri(normalizeAddress(widget.initialUrl))),
|
||||
|
|
@ -1175,7 +1170,7 @@ class _EdgeViewState extends State<_EdgeView> with WebProxyVisibility {
|
|||
onReceivedError: (c, req, err) {
|
||||
if (req.isForMainFrame ?? true) h.onMainFrameError?.call(req.url.toString(), err.description);
|
||||
},
|
||||
));
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import 'rsync_setup.dart';
|
|||
import 'schedule_editor.dart';
|
||||
import 'theme.dart';
|
||||
import 'setting_tile.dart';
|
||||
import 'sync_preview_view.dart' show OptionTextFormatter, keepOptionsTogether, optionProblemText;
|
||||
|
||||
/// 환경 설정의 복사 · 동기화 부분.
|
||||
/// - 파일 탐색기 ([rsync] false): 파일 / 폴더 복사 방법 (현재 방식 · robocopy), robocopy 옵션, 속도 제한
|
||||
|
|
@ -76,10 +75,8 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
|
|||
|
||||
/// 옵션 입력칸 (바꾸면 바로 저장, ↺ 로 기본값).
|
||||
/// 213: 예전에는 값이 기본값과 같아지거나 달라지는 순간 칸을 새로 만들어 (↺ 로 되돌릴 때 글을 바꾸려고) 포커스를 잃고 입력이 끊겼다
|
||||
Widget _options(String title, String hint, String value, String def, void Function(AppSettings, String) set, {bool rsync = false}) {
|
||||
Widget _options(String title, String hint, String value, String def, void Function(AppSettings, String) set) {
|
||||
final ctl = _optionText.putIfAbsent(title, () => TextEditingController(text: value));
|
||||
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 rsync 옵션은 칸 아래에 빨갛게 (실행 · 실시간 동기화는 막힘)
|
||||
final bad = rsync ? optionProblems(ctl.text) : const <OptionProblem>[];
|
||||
return SettingTile(
|
||||
title: Text(title),
|
||||
subtitle: Text(hint),
|
||||
|
|
@ -87,14 +84,11 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
|
|||
width: 380,
|
||||
child: TextFormField(
|
||||
controller: ctl,
|
||||
inputFormatters: const [OptionTextFormatter()],
|
||||
scrollPadding: const EdgeInsets.all(80),
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: bad.isEmpty ? null : optionProblemText(bad),
|
||||
errorMaxLines: 3,
|
||||
suffixIcon: IconButton(
|
||||
tooltip: trf('기본값으로 ({0})', [def]),
|
||||
icon: const Icon(Icons.restart_alt, size: 18),
|
||||
|
|
@ -183,7 +177,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
|
|||
value: c.settings.copySkipNewer,
|
||||
onChanged: (v) => c.updateSettings((x) => x.copySkipNewer = v),
|
||||
title: Text(tr('받는 쪽이 더 새것이면 건너뛰기')),
|
||||
subtitle: Text(keepOptionsTogether(tr('현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다'))),
|
||||
subtitle: Text(tr('현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다')),
|
||||
);
|
||||
|
||||
Widget _rsyncPart() {
|
||||
|
|
@ -193,7 +187,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
|
|||
// 111: 이미 실행한 쌍은 기억한 옵션을 쓴다는 것을 알린다
|
||||
_options(tr('rsync 옵션'), tr('예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 속도 제한은 파일 탐색기의 속도 제한을 함께 씀. '
|
||||
'새로 맞추는 폴더 쌍에 씁니다 - 이미 실행한 쌍은 기억한 옵션을 쓰며 모니터링 > 복사 · rsync 에서 따로 고칩니다'),
|
||||
s.rsyncOptions, defaultRsyncOptions, (x, v) => x.rsyncOptions = v, rsync: true),
|
||||
s.rsyncOptions, defaultRsyncOptions, (x, v) => x.rsyncOptions = v),
|
||||
_skipNewerTile(),
|
||||
SettingTile(
|
||||
title: Text(tr('rsync 가져오기')),
|
||||
|
|
@ -342,7 +336,7 @@ class _LiveSyncTile extends StatelessWidget {
|
|||
value: delete,
|
||||
onChanged: (v) => set(() => delete = v ?? false),
|
||||
title: Text(tr('원본에 없는 것을 대상에서 지우기')),
|
||||
subtitle: Text(keepOptionsTogether(tr('rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만'))),
|
||||
subtitle: Text(tr('rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만')),
|
||||
),
|
||||
SettingTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
|
|
|
|||
|
|
@ -315,18 +315,7 @@ class _DownloadRow extends StatelessWidget {
|
|||
// 136: 실패하면 진행 막대 ("준비 중…") 를 지우고 실패 · 이유 (사람 말) · [자세히] · [다시 시도]
|
||||
if (t.state != DownloadState.cancelled && t.state != DownloadState.failed) DownloadProgressBar(t: t),
|
||||
const SizedBox(height: 4),
|
||||
// 관리자 지시 (10/20): 시작되지 않는 마그넷 · 토렌트 - 이유 후보와 [다시 시도] (아무 표시 없이 "받는 중" 이던 것)
|
||||
if (t.state == DownloadState.downloading && t.extra['stalled'] == true)
|
||||
Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 4, children: [
|
||||
Text(
|
||||
'${trf('트래커 · 피어에 닿지 않습니다 ({0}초째)', [((DateTime.now().millisecondsSinceEpoch - ((t.extra['stallSince'] as int?) ?? 0)) / 1000).round()])} · '
|
||||
'${trf('이유 후보: {0}', [[for (final r in (t.extra['stallReasons'] as List?) ?? const []) stallReasonText('$r')].join(' · ')])}',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
|
||||
if (((t.extra['stallReasons'] as List?) ?? const []).contains('dns') && d.useSystemDns != null)
|
||||
TextButton(style: _small, onPressed: () => d.useSystemDns!(), child: Text(tr('시스템 DNS 로 바꾸기'))),
|
||||
TextButton(style: _small, onPressed: () => d.retryStalled(t), child: Text(tr('다시 시도'))),
|
||||
])
|
||||
else if (t.state == DownloadState.failed)
|
||||
if (t.state == DownloadState.failed)
|
||||
Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 4, children: [
|
||||
Text('$label · ${_why(t)}', style: const TextStyle(fontSize: 12, color: JjColors.danger)),
|
||||
if ((t.error ?? '').trim().isNotEmpty)
|
||||
|
|
@ -372,13 +361,6 @@ class _DownloadRow extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// 멈춘 마그넷 · 토렌트의 이유 후보 글 (Aria2Backend 가 정한 이름)
|
||||
String stallReasonText(String code) => switch (code) {
|
||||
'ipv6only' => tr('인터넷 IPv4 가 없음 (IPv6 만)'),
|
||||
'dns' => tr('DNS 강제 (환경 설정 > 네트워크 · DNS)'),
|
||||
_ => tr('방화벽 · 공유기가 토렌트를 막음'),
|
||||
};
|
||||
|
||||
/// "받은 크기 / 전체 크기" (다 받았으면 전체 크기만, 모르면 빈 글)
|
||||
String downloadSizeText(DownloadTask t) {
|
||||
final got = t.receivedBytes, all = t.totalBytes;
|
||||
|
|
@ -400,11 +382,7 @@ class DownloadProgressBar extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final done = t.state == DownloadState.done;
|
||||
final value = done ? 1.0 : t.progress;
|
||||
// 관리자 지시 (10/20, 28 확인): 멈춘 마그넷 · 토렌트 (줄의 주황 "트래커 · 피어에 닿지 않습니다") 는 막대를 움직이지 않고
|
||||
// 주황으로 "멈춤" - 움직이는 "준비 중…" 이면 받는 중인 것처럼 보였다
|
||||
final stalled = t.state == DownloadState.downloading && t.extra['stalled'] == true;
|
||||
final color = switch (t.state) {
|
||||
_ when stalled => Colors.orangeAccent,
|
||||
DownloadState.done => JjColors.success,
|
||||
DownloadState.failed => JjColors.danger,
|
||||
DownloadState.paused => Colors.amber,
|
||||
|
|
@ -418,10 +396,7 @@ class DownloadProgressBar extends StatelessWidget {
|
|||
? tr('실패') // 136: 실패한 것에 "준비 중…" 이 남지 않게 (작은 다운로드 상자 등)
|
||||
: t.state == DownloadState.queued || t.extra['waiting'] == true
|
||||
? tr('대기 중')
|
||||
: stalled
|
||||
// 관리자 지시 (10/20, 28 지적): 영어 "Stopped" 는 일시 정지 · 취소로 읽혀 따로 열쇠 ("Stalled")
|
||||
? tr('멈춤 (시작 안 됨)')
|
||||
: tr('준비 중…'))
|
||||
: tr('준비 중…'))
|
||||
: '${(value * 100).toStringAsFixed(1)}%${size.isEmpty ? '' : ' · $size'}';
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
|
|
@ -430,10 +405,9 @@ class DownloadProgressBar extends StatelessWidget {
|
|||
child: Stack(fit: StackFit.expand, children: [
|
||||
LinearProgressIndicator(
|
||||
// 진행률을 모를 때(받기 준비 · 마그넷 정보 받는 중)는 움직이는 막대
|
||||
value: value ?? (t.state == DownloadState.downloading && !stalled ? null : 0),
|
||||
value: value ?? (t.state == DownloadState.downloading ? null : 0),
|
||||
minHeight: height,
|
||||
// 멈춘 줄은 빈 막대도 주황 기운으로 (회색 빈 막대면 그냥 시작 전처럼 보임)
|
||||
backgroundColor: stalled ? Colors.orangeAccent.withValues(alpha: 0.28) : JjColors.panelHigh,
|
||||
backgroundColor: JjColors.panelHigh,
|
||||
color: color.withValues(alpha: 0.85),
|
||||
),
|
||||
Center(
|
||||
|
|
|
|||
|
|
@ -1,118 +0,0 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../core/file_ops.dart';
|
||||
import '../core/playlist.dart' show isVideoFile;
|
||||
import '../l10n/tr.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// X-plore 비교 g: 격자 보기 - 지금 폴더의 그림 · 동영상을 썸네일 격자로 (X-plore 의 그림 보기 방식).
|
||||
/// 누르면 열기 (그림 보기 · 재생), 길게 누르면 탐색기에서 그 자리로. 돌려주는 값: "그 자리로" 고른 경로 (없으면 null)
|
||||
class ExplorerGridPage extends StatefulWidget {
|
||||
final String title;
|
||||
final List<FileEntry> entries;
|
||||
|
||||
/// 그림 확장자 (환경 설정 > 보기)
|
||||
final List<String> imageExts;
|
||||
|
||||
/// 동영상 썸네일 (없으면 아이콘)
|
||||
final Future<File?> Function(FileEntry e) videoThumb;
|
||||
final Future<void> Function(FileEntry e) onOpen;
|
||||
const ExplorerGridPage({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.entries,
|
||||
required this.imageExts,
|
||||
required this.videoThumb,
|
||||
required this.onOpen,
|
||||
});
|
||||
|
||||
/// 격자에 넣을 것 (그림 · 동영상)
|
||||
static List<FileEntry> pick(List<FileEntry> all, List<String> imageExts) =>
|
||||
[for (final e in all) if (!e.isDir && (imageExts.contains(e.ext) || isVideoFile(e.path))) e];
|
||||
|
||||
@override
|
||||
State<ExplorerGridPage> createState() => _ExplorerGridPageState();
|
||||
}
|
||||
|
||||
class _ExplorerGridPageState extends State<ExplorerGridPage> {
|
||||
/// 칸 크기 (밀어서 키우기 · 줄이기 대신 단추)
|
||||
double _cell = 140;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = ExplorerGridPage.pick(widget.entries, widget.imageExts);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(trf('{0} · 그림 · 동영상 {1}개', [widget.title, items.length]), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: tr('작게'),
|
||||
icon: const Icon(Icons.zoom_out),
|
||||
onPressed: _cell > 80 ? () => setState(() => _cell -= 30) : null,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: tr('크게'),
|
||||
icon: const Icon(Icons.zoom_in),
|
||||
onPressed: _cell < 320 ? () => setState(() => _cell += 30) : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: items.isEmpty
|
||||
? Center(child: Text(tr('이 폴더에는 그림 · 동영상이 없습니다')))
|
||||
: GridView.builder(
|
||||
padding: const EdgeInsets.all(6),
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: _cell, mainAxisSpacing: 6, crossAxisSpacing: 6),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (_, i) => _tile(items[i]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tile(FileEntry e) {
|
||||
final video = isVideoFile(e.path);
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
final px = (_cell * dpr).round();
|
||||
Widget picture;
|
||||
if (video) {
|
||||
picture = FutureBuilder<File?>(
|
||||
future: widget.videoThumb(e),
|
||||
builder: (_, s) => s.data == null
|
||||
? const Center(child: Icon(Icons.movie_outlined, size: 40, color: JjColors.textDim))
|
||||
: Image.file(s.data!, fit: BoxFit.cover, cacheWidth: px),
|
||||
);
|
||||
} else {
|
||||
picture = Image.file(File(e.path),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: px,
|
||||
errorBuilder: (_, _, _) => const Center(child: Icon(Icons.broken_image_outlined, size: 40, color: JjColors.textDim)));
|
||||
}
|
||||
return Tooltip(
|
||||
message: e.name,
|
||||
child: Material(
|
||||
color: JjColors.panelHigh,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () => widget.onOpen(e),
|
||||
onLongPress: () => Navigator.pop(context, e.path),
|
||||
child: Stack(fit: StackFit.expand, children: [
|
||||
picture,
|
||||
if (video) const Align(alignment: Alignment.center, child: Icon(Icons.play_circle_outline, color: Colors.white70, size: 32)),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
color: Colors.black54,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
child: Text(e.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: Colors.white)),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,279 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../core/file_attributes.dart';
|
||||
import '../core/file_ops.dart' show formatSize;
|
||||
import '../l10n/tr.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// X-plore 비교 P3: 해시 · 16진 보기 · 폴더 속성 (읽기만 - 파일을 바꾸지 않는다)
|
||||
|
||||
/// 파일의 MD5 · SHA-256 (조각으로 읽어 큰 파일도 메모리를 쓰지 않음). [onProgress] 0~1, [cancelled] 면 null
|
||||
Future<({String md5, String sha256})?> fileHashes(String path, {void Function(double)? onProgress, bool Function()? cancelled}) async {
|
||||
final f = File(path);
|
||||
final total = await f.length();
|
||||
final m = _Sink(), s = _Sink();
|
||||
final mIn = md5.startChunkedConversion(m), sIn = sha256.startChunkedConversion(s);
|
||||
var done = 0;
|
||||
await for (final chunk in f.openRead()) {
|
||||
if (cancelled?.call() ?? false) return null;
|
||||
mIn.add(chunk);
|
||||
sIn.add(chunk);
|
||||
done += chunk.length;
|
||||
onProgress?.call(total == 0 ? 1 : done / total);
|
||||
}
|
||||
mIn.close();
|
||||
sIn.close();
|
||||
return (md5: '${m.value}', sha256: '${s.value}');
|
||||
}
|
||||
|
||||
class _Sink implements Sink<Digest> {
|
||||
Digest? value;
|
||||
@override
|
||||
void add(Digest data) => value = data;
|
||||
@override
|
||||
void close() {}
|
||||
}
|
||||
|
||||
/// 해시 창 - 계산 (진행 · 취소) 뒤 MD5 · SHA-256, 붙여 넣은 값과 비교
|
||||
Future<void> showFileHash(BuildContext context, String path) async {
|
||||
var progress = 0.0;
|
||||
var cancelled = false;
|
||||
({String md5, String sha256})? result;
|
||||
Object? error;
|
||||
final compare = TextEditingController();
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(builder: (ctx, set) {
|
||||
if (result == null && error == null && progress == 0) {
|
||||
progress = 0.0001;
|
||||
fileHashes(path, onProgress: (v) {
|
||||
if (ctx.mounted) set(() => progress = v);
|
||||
}, cancelled: () => cancelled)
|
||||
.then((r) {
|
||||
if (ctx.mounted) set(() => result = r);
|
||||
}, onError: (Object e) {
|
||||
if (ctx.mounted) set(() => error = e);
|
||||
});
|
||||
}
|
||||
final want = compare.text.trim().toLowerCase();
|
||||
final match = result != null && want.isNotEmpty ? (want == result!.md5 || want == result!.sha256) : null;
|
||||
Widget line(String name, String v) => Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Row(children: [
|
||||
SizedBox(width: 72, child: Text(name, style: const TextStyle(fontWeight: FontWeight.w600))),
|
||||
Expanded(child: SelectableText(v, style: const TextStyle(fontFamily: 'monospace', fontSize: 12))),
|
||||
IconButton(
|
||||
tooltip: tr('복사'),
|
||||
icon: const Icon(Icons.copy, size: 16),
|
||||
onPressed: () => Clipboard.setData(ClipboardData(text: v)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(trf('해시 · {0}', [p.basename(path)])),
|
||||
content: SizedBox(
|
||||
width: 560,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (error != null) Text(trf('읽지 못했습니다: {0}', [error]), style: const TextStyle(color: JjColors.danger)),
|
||||
if (result == null && error == null) LinearProgressIndicator(value: progress),
|
||||
if (result != null) ...[
|
||||
line('MD5', result!.md5),
|
||||
line('SHA-256', result!.sha256),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: compare,
|
||||
decoration: InputDecoration(labelText: tr('비교할 해시'), helperText: tr('받은 곳에서 알려 준 해시를 붙여넣으세요'), helperMaxLines: 3, isDense: true),
|
||||
onChanged: (_) => set(() {}),
|
||||
),
|
||||
if (match != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(match ? tr('같습니다 ✔') : tr('다릅니다 - 파일이 바뀌었거나 다른 파일입니다'),
|
||||
style: TextStyle(color: match ? Colors.greenAccent : JjColors.danger)),
|
||||
),
|
||||
],
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
cancelled = true;
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: Text(result == null && error == null ? tr('취소') : tr('닫기')),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
// 창이 닫히는 동안 (애니메이션) 글 칸이 아직 쓰므로 바로 dispose 하면 "used after being disposed" (15 캡처) - 닫힌 뒤에
|
||||
Future<void>.delayed(const Duration(milliseconds: 500), compare.dispose);
|
||||
}
|
||||
|
||||
/// 16진 보기: 한 줄 16 바이트 (위치 · 16진 · 글자). 큰 파일은 [더 읽기]
|
||||
class HexViewerPage extends StatefulWidget {
|
||||
final String path;
|
||||
final int chunk;
|
||||
const HexViewerPage({super.key, required this.path, this.chunk = 64 * 1024});
|
||||
|
||||
/// 한 줄 ([per] 바이트, 처음 16): "00000010 41 42 ... AB.."
|
||||
static String line(int offset, List<int> bytes, {int per = 16}) {
|
||||
final hex = StringBuffer();
|
||||
for (var i = 0; i < per; i++) {
|
||||
hex.write(i < bytes.length ? bytes[i].toRadixString(16).padLeft(2, '0') : ' ');
|
||||
hex.write(i == 7 && per > 8 ? ' ' : ' ');
|
||||
}
|
||||
final text = String.fromCharCodes([for (final b in bytes) b >= 0x20 && b < 0x7f ? b : 0x2e]);
|
||||
return '${offset.toRadixString(16).padLeft(8, '0')} $hex $text';
|
||||
}
|
||||
|
||||
@override
|
||||
State<HexViewerPage> createState() => _HexViewerPageState();
|
||||
}
|
||||
|
||||
class _HexViewerPageState extends State<HexViewerPage> {
|
||||
final _data = <int>[];
|
||||
int _size = 0;
|
||||
Object? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_more();
|
||||
}
|
||||
|
||||
Future<void> _more() async {
|
||||
try {
|
||||
final f = File(widget.path);
|
||||
_size = await f.length();
|
||||
final to = (_data.length + widget.chunk).clamp(0, _size);
|
||||
await for (final x in f.openRead(_data.length, to)) {
|
||||
_data.addAll(x);
|
||||
}
|
||||
} catch (e) {
|
||||
_error = e;
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 좁은 화면 (폰 세로) 은 한 줄 8 바이트 - 16 이면 끝의 글자 칸이 화면 밖으로 잘렸다 (15 캡처, 412 폭)
|
||||
final per = MediaQuery.sizeOf(context).width < 560 ? 8 : 16;
|
||||
final rows = (_data.length + per - 1) ~/ per;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(trf('16진 보기 · {0}', [p.basename(widget.path)]), maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
body: _error != null
|
||||
? Center(child: Text(trf('읽지 못했습니다: {0}', [_error])))
|
||||
: Column(children: [
|
||||
Expanded(
|
||||
child: Scrollbar(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: rows,
|
||||
itemExtent: 18,
|
||||
itemBuilder: (_, i) => Text(
|
||||
HexViewerPage.line(i * per, _data.sublist(i * per, (i * per + per).clamp(0, _data.length)), per: per),
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_data.length < _size)
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Text(trf('{0} / {1} 읽음', [formatSize(_data.length), formatSize(_size)]),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
),
|
||||
FilledButton.tonal(onPressed: _more, child: Text(tr('더 읽기'))),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 폴더 속성: 안의 파일 · 폴더 수, 크기, 만든 · 바뀐 때 (링크 · junction 은 따라가지 않음)
|
||||
Future<({int files, int folders, int bytes, int links})> folderStats(String dir, {bool Function()? cancelled}) async {
|
||||
var files = 0, folders = 0, bytes = 0, links = 0;
|
||||
await for (final e in Directory(dir).list(recursive: true, followLinks: false).handleError((_) {})) {
|
||||
if (cancelled?.call() ?? false) break;
|
||||
if (e is Link) {
|
||||
links++;
|
||||
} else if (e is Directory) {
|
||||
folders++;
|
||||
} else if (e is File) {
|
||||
files++;
|
||||
try {
|
||||
bytes += await e.length();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
return (files: files, folders: folders, bytes: bytes, links: links);
|
||||
}
|
||||
|
||||
Future<void> showFolderProperties(BuildContext context, String dir) async {
|
||||
var cancelled = false;
|
||||
final stat = await FileStat.stat(dir);
|
||||
final attrs = readFileAttributes(dir);
|
||||
if (!context.mounted) return;
|
||||
String date(DateTime d) =>
|
||||
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
|
||||
final future = folderStats(dir, cancelled: () => cancelled);
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(trf('폴더 속성 · {0}', [p.basename(dir)])),
|
||||
content: SizedBox(
|
||||
width: 480,
|
||||
child: FutureBuilder(
|
||||
future: future,
|
||||
builder: (_, s) {
|
||||
final r = s.data;
|
||||
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
SelectableText(dir, style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
const SizedBox(height: 8),
|
||||
if (r == null) const LinearProgressIndicator(),
|
||||
if (r != null) ...[
|
||||
Text(trf('크기: {0} ({1} 바이트)', [formatSize(r.bytes), r.bytes])),
|
||||
Text(trf('파일 {0}개 · 폴더 {1}개', [r.files, r.folders])),
|
||||
if (r.links > 0) Text(trf('링크 · junction {0}개 (따라가지 않고 세지 않음)', [r.links])),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
// 관리자 지시 10/18: 만든 때 · 숨김 · 읽기 전용 (보기만)
|
||||
if (attrs?.created != null) Text(trf('만든 때: {0}', [date(attrs!.created!)])),
|
||||
Text(trf('바뀐 때: {0}', [date(stat.modified)])),
|
||||
Text(trf('열어 본 때: {0}', [date(stat.accessed)])),
|
||||
Text(trf('속성: {0}', [attrs == null || attrs.labels.isEmpty ? tr('없음') : attrs.labels.join(' · ')])),
|
||||
]);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
cancelled = true;
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: Text(tr('닫기')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
cancelled = true;
|
||||
}
|
||||
|
|
@ -1,320 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/app_controller.dart';
|
||||
import '../core/ftp.dart';
|
||||
import '../core/secret_gate.dart';
|
||||
import '../core/webdav.dart' show DavLockedException;
|
||||
import '../l10n/tr.dart';
|
||||
import 'confirm.dart';
|
||||
import 'setting_tile.dart';
|
||||
import 'theme.dart';
|
||||
import 'webdav_settings.dart' show davServerUsers;
|
||||
|
||||
/// l: 환경 설정 > 파일 탐색기 > FTP · FTPS: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인). SFTP 와 같은 틀.
|
||||
/// 서버마다 파일 탐색기 · Rsync 화면 위쪽 (WebDAV 옆) 에 탭이 생긴다. 평문 FTP 는 목록 · 탭 · 창에 "암호화 안 됨" 을 늘 보인다
|
||||
class FtpSettings extends StatelessWidget {
|
||||
final AppController c;
|
||||
const FtpSettings({super.key, required this.c});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final servers = c.settings.ftpServers;
|
||||
return Column(children: [
|
||||
SettingTile(
|
||||
leading: const Icon(Icons.folder_shared_outlined),
|
||||
title: const Text('FTP · FTPS'),
|
||||
subtitle: Text(tr('FTP 서버의 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 탭). '
|
||||
'처음 값은 암호화하는 FTPS 이고, 비밀번호는 이 기기의 안전 저장소에만 둡니다.')),
|
||||
trailing: TextButton.icon(
|
||||
onPressed: () => editFtpServer(context, c),
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: Text(tr('추가')),
|
||||
),
|
||||
),
|
||||
for (final s in servers)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: SettingTile(
|
||||
dense: true,
|
||||
leading: Icon(s.tls ? Icons.folder_shared : Icons.warning_amber_outlined, color: s.tls ? JjColors.accent : JjColors.danger),
|
||||
title: Text(s.label),
|
||||
subtitle: Text(
|
||||
[
|
||||
'${s.user.isEmpty ? '' : '${s.user}@'}${s.host}:${s.port}',
|
||||
ftpSecurityLabel(s.security),
|
||||
if (s.root.isNotEmpty) s.root,
|
||||
].join(' · '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: s.tls ? null : const TextStyle(color: JjColors.danger)),
|
||||
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
IconButton(tooltip: tr('고치기'), icon: const Icon(Icons.edit_outlined, size: 18), onPressed: () => editFtpServer(context, c, old: s)),
|
||||
IconButton(
|
||||
tooltip: tr('지우기'),
|
||||
icon: const Icon(Icons.delete_outline, size: 18, color: JjColors.textDim),
|
||||
onPressed: () => deleteFtpServer(context, c, s),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
String ftpSecurityLabel(String security) => switch (security) {
|
||||
'implicit' => tr('FTPS (암시, 보통 990)'),
|
||||
'none' => tr('⚠ 평문 FTP (암호화 안 됨)'),
|
||||
_ => tr('FTPS (명시, AUTH TLS)'),
|
||||
};
|
||||
|
||||
/// 서버 지우기 (쓰는 곳을 알린 뒤 확인). 지웠으면 true
|
||||
Future<bool> deleteFtpServer(BuildContext context, AppController c, FtpServer s) async {
|
||||
final users = davServerUsers(c.settings, s.id);
|
||||
final ok = await confirmAction(
|
||||
context,
|
||||
title: trf('FTP 서버 "{0}" 을(를) 지울까요?', [s.label]),
|
||||
body: tr('서버 설정과 이 기기에 저장된 비밀번호 · 믿은 인증서를 지웁니다. 서버의 파일은 그대로입니다.') +
|
||||
(users.isEmpty
|
||||
? ''
|
||||
: '\n\n${trf('이 서버를 쓰는 것 {0}개 (지워도 목록에는 남고, 실행하면 서버가 없다고 알립니다):', [users.length])}\n'
|
||||
'${users.take(8).map((u) => '• $u').join('\n')}${users.length > 8 ? '\n…' : ''}'),
|
||||
ok: tr('지우기'),
|
||||
);
|
||||
if (!ok) return false;
|
||||
await c.updateSettings((x) => x.ftpServers = [for (final y in x.ftpServers) if (y.id != s.id) y]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 처음 보는 자체 서명 인증서를 믿을지 묻는다 (TOFU). 믿으면 true. 바뀐 인증서는 여기서 믿지 않는다 (서버 고치기에서 지운 뒤)
|
||||
Future<bool> askTrustFtpCert(BuildContext context, String serverLabel, FtpCertException e) async {
|
||||
if (e.changed) {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
scrollable: true,
|
||||
icon: const Icon(Icons.gpp_bad_outlined, color: JjColors.danger),
|
||||
title: Text(trf('"{0}" 서버의 인증서가 바뀌었습니다', [serverLabel])),
|
||||
content: SelectableText(
|
||||
'${tr('중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 서버 인증서를 바꾼 것이 확실할 때만, 서버 설정의 [믿은 인증서 지우기] 뒤 다시 연결하세요.')}\n\n'
|
||||
'${tr('예전')}: ${e.previous}\n${tr('지금')}: ${e.fingerprint}',
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
actions: [FilledButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))],
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
scrollable: true,
|
||||
icon: const Icon(Icons.fingerprint),
|
||||
title: Text(trf('"{0}" 의 인증서를 처음 봅니다', [serverLabel])),
|
||||
content: SelectableText(
|
||||
'${tr('자체 서명 인증서입니다. 지문이 서버 (NAS 설정) 에서 본 것과 같은지 확인하세요. 믿으면 이 기기에 저장하고, 다음부터 인증서가 바뀌면 연결하지 않고 알립니다.')}\n\n'
|
||||
'${e.subject}\n${e.fingerprint}',
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('믿고 연결'))),
|
||||
],
|
||||
),
|
||||
);
|
||||
return ok == true;
|
||||
}
|
||||
|
||||
/// 믿은 인증서를 설정에 저장 (탐색기에서 처음 연결할 때)
|
||||
Future<void> trustFtpCert(AppController c, String id, FtpCertException e) =>
|
||||
c.updateSettings((x) => x.ftpServers = [for (final y in x.ftpServers) y.id == id ? y.copyWith(certFingerprint: e.fingerprint) : y]);
|
||||
|
||||
/// FTP 서버 추가 · 고치기 창. 저장한 서버를 돌려준다 (취소면 null)
|
||||
Future<FtpServer?> editFtpServer(BuildContext context, AppController c, {FtpServer? old}) async {
|
||||
if (old != null && old.password.isNotEmpty && !await SecretGate.pass(force: true)) return null;
|
||||
if (!context.mounted) return null;
|
||||
final name = TextEditingController(text: old?.name ?? '');
|
||||
final host = TextEditingController(text: old?.host ?? '');
|
||||
final port = TextEditingController(text: '${old?.port ?? 21}');
|
||||
final user = TextEditingController(text: old?.user ?? '');
|
||||
final pass = TextEditingController(text: old?.password ?? '');
|
||||
final root = TextEditingController(text: old?.root ?? '');
|
||||
var security = old?.security ?? 'explicit';
|
||||
var fingerprint = old?.certFingerprint ?? '';
|
||||
var hide = true;
|
||||
String? result;
|
||||
var passed = false;
|
||||
var testing = false;
|
||||
|
||||
FtpServer current() => FtpServer(
|
||||
id: old?.id ?? 'ftp${DateTime.now().microsecondsSinceEpoch}',
|
||||
name: name.text.trim(),
|
||||
host: host.text.trim(),
|
||||
port: int.tryParse(port.text.trim()) ?? 21,
|
||||
user: user.text.trim(),
|
||||
root: root.text.trim(),
|
||||
password: pass.text,
|
||||
security: security,
|
||||
certFingerprint: fingerprint,
|
||||
);
|
||||
|
||||
final saved = await showDialog<FtpServer>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, set) {
|
||||
Future<void> test() async {
|
||||
FocusScope.of(ctx).unfocus();
|
||||
set(() {
|
||||
testing = true;
|
||||
result = null;
|
||||
});
|
||||
for (var round = 0; round < 2; round++) {
|
||||
final client = FtpRemote(current());
|
||||
try {
|
||||
final items = await client.list('/');
|
||||
passed = true;
|
||||
result = trf('연결됨: 항목 {0}개', [items.length]);
|
||||
} on FtpCertException catch (e) {
|
||||
passed = false;
|
||||
result = e.message;
|
||||
if (!e.changed && ctx.mounted && await askTrustFtpCert(ctx, current().label, e)) {
|
||||
fingerprint = e.fingerprint;
|
||||
result = null;
|
||||
client.close();
|
||||
continue;
|
||||
}
|
||||
} on DavLockedException catch (e) {
|
||||
passed = false;
|
||||
result = e.message;
|
||||
} catch (e) {
|
||||
passed = false;
|
||||
result = '$e';
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (ctx.mounted) set(() => testing = false);
|
||||
}
|
||||
|
||||
final ok = host.text.trim().isNotEmpty && (int.tryParse(port.text.trim()) ?? 0) > 0;
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(old == null ? tr('FTP 서버 추가') : tr('FTP 서버 고치기')),
|
||||
content: SizedBox(
|
||||
width: 520,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
TextField(controller: name, decoration: InputDecoration(labelText: tr('이름'), helperText: tr('탭에 보입니다 · 비우면 주소'), helperMaxLines: 3)),
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: host,
|
||||
keyboardType: TextInputType.url,
|
||||
decoration: InputDecoration(labelText: tr('서버 주소'), helperText: tr('예: nas.local · 192.168.0.10 · 100.x.y.z (Tailscale)'), helperMaxLines: 3),
|
||||
onChanged: (_) => set(() {}),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: TextField(
|
||||
controller: port,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(labelText: tr('포트')),
|
||||
onChanged: (_) => set(() {}),
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
Text(tr('암호화'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
RadioGroup<String>(
|
||||
groupValue: security,
|
||||
onChanged: (x) => set(() {
|
||||
final was = security;
|
||||
security = x ?? 'explicit';
|
||||
// 포트를 그대로 두었으면 방식에 맞게 (21 ↔ 990)
|
||||
if (port.text.trim() == (was == 'implicit' ? '990' : '21')) port.text = security == 'implicit' ? '990' : '21';
|
||||
}),
|
||||
child: Column(children: [
|
||||
for (final v in const ['explicit', 'implicit', 'none'])
|
||||
RadioListTile<String>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
value: v,
|
||||
title: Text(ftpSecurityLabel(v), style: v == 'none' ? const TextStyle(color: JjColors.danger) : null),
|
||||
),
|
||||
]),
|
||||
),
|
||||
if (security == 'none')
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
color: JjColors.danger.withValues(alpha: 0.12),
|
||||
child: Text(
|
||||
tr('⚠ 평문 FTP 는 아이디 · 비밀번호 · 파일이 암호화되지 않은 채 오갑니다. 같은 망의 다른 사람이 볼 수 있습니다. 서버가 FTPS 를 못 할 때만, 믿을 수 있는 집 안 망에서만 쓰세요.'),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.danger)),
|
||||
),
|
||||
TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디 (비우면 익명)'))),
|
||||
TextField(
|
||||
controller: pass,
|
||||
obscureText: hide,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr('비밀번호'),
|
||||
suffixIcon: IconButton(
|
||||
tooltip: hide ? tr('보이기') : tr('가리기'),
|
||||
icon: Icon(hide ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18),
|
||||
onPressed: () => set(() => hide = !hide),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: root,
|
||||
decoration: InputDecoration(labelText: tr('시작 폴더'), helperText: tr('비우면 로그인한 곳 · 예: /volume1/video · 탭의 맨 위가 됩니다'), helperMaxLines: 3),
|
||||
),
|
||||
if (security != 'none') ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
fingerprint.isEmpty
|
||||
? tr('인증서: 공인 인증서면 그대로 연결, 자체 서명이면 [연결 확인] 때 지문을 보이고 묻습니다')
|
||||
: '${tr('믿은 인증서')}: $fingerprint',
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
if (fingerprint.isNotEmpty)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: () async {
|
||||
if (!await SecretGate.pass(force: true)) return;
|
||||
set(() {
|
||||
fingerprint = '';
|
||||
result = tr('믿은 인증서를 지웠습니다. [연결 확인] 으로 새 인증서를 확인하세요');
|
||||
passed = true;
|
||||
});
|
||||
},
|
||||
child: Text(tr('믿은 인증서 지우기')),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (testing) const LinearProgressIndicator(),
|
||||
if (result != null) Text(result!, style: TextStyle(fontSize: 12, color: passed ? Colors.greenAccent : Colors.redAccent)),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
if (old != null)
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
if (await deleteFtpServer(ctx, c, old) && ctx.mounted) Navigator.pop(ctx);
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.redAccent),
|
||||
child: Text(tr('지우기')),
|
||||
),
|
||||
TextButton(onPressed: !ok || testing ? null : test, child: Text(tr('연결 확인'))),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))),
|
||||
FilledButton(onPressed: ok ? () => Navigator.pop(ctx, current()) : null, child: Text(tr('저장'))),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (saved == null) return null;
|
||||
await c.updateSettings((x) => x.ftpServers = [
|
||||
for (final y in x.ftpServers) y.id == saved.id ? saved : y,
|
||||
if (!x.ftpServers.any((y) => y.id == saved.id)) saved,
|
||||
]);
|
||||
return saved;
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/app_controller.dart';
|
||||
import '../core/file_ops.dart' show samePath;
|
||||
import '../core/vfs.dart' show vBasename, vDirname;
|
||||
import '../l10n/tr.dart';
|
||||
import 'path_label.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// X-plore 숨기기 (관리자 결정 10/18): 환경 설정 > 파일 탐색기 [목록에서 숨긴 항목] - 숨긴 것을 보고 하나씩 · 모두 풀기.
|
||||
/// 파일은 건드리지 않는다 (목록에서만 다시 보이게)
|
||||
Future<void> showHiddenItems(BuildContext context, AppController c) => showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(builder: (ctx, set) {
|
||||
final list = c.settings.explorerHiddenItems;
|
||||
Future<void> unhide(List<String> which) async {
|
||||
await c.updateSettings((s) => s.explorerHiddenItems = [
|
||||
for (final x in s.explorerHiddenItems)
|
||||
if (!which.any((w) => samePath(w, x))) x,
|
||||
]);
|
||||
set(() {});
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(trf('목록에서 숨긴 항목 ({0}개)', [list.length])),
|
||||
content: SizedBox(
|
||||
width: 520,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(tr('풀면 파일 탐색기 목록에 다시 보입니다. 파일 자체는 바꾸지 않습니다.'),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
const SizedBox(height: 8),
|
||||
if (list.isEmpty) Text(tr('숨긴 항목이 없습니다')),
|
||||
for (final x in list)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
title: Text(vBasename(x), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(friendlyPath(vDirname(x)), maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
trailing: IconButton(
|
||||
tooltip: tr('숨기기 풀기'),
|
||||
icon: const Icon(Icons.visibility_outlined),
|
||||
onPressed: () => unhide([x]),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
if (list.length > 1) TextButton(onPressed: () => unhide([...list]), child: Text(tr('모두 풀기'))),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기'))),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
|
@ -143,7 +143,6 @@ Future<void> clearStoredSecrets(AppController c) => c.updateSettings((s) => s
|
|||
// 88: SFTP 의 비밀번호 · 개인 키 · 키 암호도 (믿은 호스트 키는 비밀이 아니라 남김)
|
||||
..sftpServers = [for (final x in s.sftpServers) x.copyWith(password: '', privateKey: '', keyPassphrase: '')]
|
||||
..smbServers = [for (final x in s.smbServers) x.copyWith(password: '')]
|
||||
..ftpServers = [for (final x in s.ftpServers) x.copyWith(password: '')]
|
||||
// AI 서비스 API 키도 (문구 "비밀번호 · API 키" 와 같게 - 예전에는 남았다, 관리자 결정 10/17)
|
||||
..aiServices = [for (final x in s.aiServices) x.copyWith(apiKey: '')]
|
||||
..openSubtitlesKey = ''
|
||||
|
|
@ -161,7 +160,7 @@ Future<bool> forgotMaster(BuildContext context, AppController c, MasterLock lock
|
|||
context,
|
||||
title: tr('저장된 비밀번호를 지우고 마스터 초기화'),
|
||||
body: tr('마스터 비밀번호를 모르면 저장된 비밀번호를 쓸 수 없습니다. 비밀번호가 새어 나가지 않도록, '
|
||||
'저장된 WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키와 API 키를 지우고 마스터 비밀번호를 초기화합니다. '
|
||||
'저장된 WebDAV · OpenSubtitles 비밀번호와 API 키를 지우고 마스터 비밀번호를 초기화합니다. '
|
||||
'서버 목록 · 아이디 · 다른 설정은 그대로이며, 비밀번호는 다시 넣어야 합니다.'),
|
||||
ok: tr('지우고 초기화'),
|
||||
);
|
||||
|
|
@ -197,15 +196,13 @@ Future<void> showSuperReset(BuildContext context, AppController c, MasterLock lo
|
|||
value: secrets,
|
||||
onChanged: (v) => set(() => secrets = v!),
|
||||
title: Text(tr('저장된 실제 비밀번호 · API 키')),
|
||||
// 145: 10/18 에 생긴 SFTP · SMB · FTP 비밀번호 · 키도 지운다 (clearStoredSecrets)
|
||||
subtitle: Text(tr('WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다')),
|
||||
subtitle: Text(tr('WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다')),
|
||||
),
|
||||
CheckboxListTile(
|
||||
value: settings,
|
||||
onChanged: (v) => set(() => settings = v!),
|
||||
title: Text(tr('앱 설정 전체 (처음 상태로)')),
|
||||
// 145: 파일 탐색기 즐겨찾기 (73) 는 설정 안에 있어 함께 지워진다 - 예전 글은 "즐겨찾기는 그대로"
|
||||
subtitle: Text(tr('서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다')),
|
||||
subtitle: Text(tr('WebDAV 서버 목록과 그 저장된 비밀번호도 지워집니다. 동영상 목록 · 즐겨찾기 · 파일은 그대로입니다')),
|
||||
),
|
||||
]),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import 'path_label.dart';
|
|||
import 'rsync_setup.dart';
|
||||
import 'schedule_editor.dart';
|
||||
import '../core/sync_preview.dart' show optionsRemoveSource;
|
||||
import 'sync_preview_view.dart' show FilterLimitNote, OptionProblemNote, OptionTextFormatter, confirmDeletingRun, effectiveRunText, keepOptionsTogether, unbreakableOption;
|
||||
import 'sync_preview_view.dart' show confirmDeletingRun, effectiveRunText;
|
||||
import 'theme.dart';
|
||||
|
||||
/// 모니터링 (파일 탐색기 > 모니터링): 복사 · rsync 와 lsync (실시간 동기화) 를 따로 보여 준다.
|
||||
|
|
@ -80,85 +80,38 @@ class MonitorPage extends StatelessWidget {
|
|||
Future<void> confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPair x) async {
|
||||
final list = live.toDelete[LiveSync.keyOf(x)] ?? const <String>[];
|
||||
if (list.isEmpty) return;
|
||||
final pick = await _liveDeleteDialog(context, live, x, list, live.toDeleteExcluded[LiveSync.keyOf(x)] ?? const [], now: false);
|
||||
if (pick == null) return;
|
||||
await live.decideDelete(x, delete: pick);
|
||||
}
|
||||
|
||||
/// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙이 있어 자동으로 지우지 않는 쌍의 [지금 지우기 (확인)].
|
||||
/// 빨간 FilterLimitNote 와 [알고 실행합니다] 체크를 거쳐 이번 한 번만 지운다 (다음 자동 맞추기는 다시 보내기만)
|
||||
Future<void> confirmLiveDeleteNow(BuildContext context, LiveSync live, LiveSyncPair x) async {
|
||||
final (list, excl) = await live.previewDeletes(x);
|
||||
if (!context.mounted) return;
|
||||
final pick = await _liveDeleteDialog(context, live, x, list, excl, now: true);
|
||||
if (pick == true) await live.deleteNow(x);
|
||||
}
|
||||
|
||||
Future<bool?> _liveDeleteDialog(BuildContext context, LiveSync live, LiveSyncPair x, List<String> list, List<String> exclIn,
|
||||
{required bool now}) {
|
||||
// 관리자 3a 조건 (10/20): --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 은 따로 묶어 알고 누르게
|
||||
final excl = exclIn.toSet();
|
||||
final plain = [for (final n in list) if (!excl.contains(n)) n];
|
||||
final filtered = [for (final n in list) if (excl.contains(n)) n];
|
||||
// P0: 미리 볼 수 없는 규칙 - 목록이 실제와 다를 수 있음을 빨갛게, 체크해야 [지우고 맞추기]
|
||||
final limits = live.unsupportedOf(x);
|
||||
var ack = false;
|
||||
return showDialog<bool>(
|
||||
final pick = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, set) => AlertDialog(
|
||||
title: Text(list.isEmpty ? tr('대상에서 지울까요?') : trf('대상에서 {0}개를 지울까요?', [list.length])),
|
||||
content: SizedBox(
|
||||
width: 560,
|
||||
// 세로 폰에서도 목록 (특히 거른 것 묶음) 이 더 보이게 화면 높이를 따라 (360 ~ 520)
|
||||
height: (MediaQuery.sizeOf(ctx).height * 0.55).clamp(360.0, 520.0),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (limits.isNotEmpty) ...[
|
||||
FilterLimitNote(unsupported: limits, deletes: true, ack: ack, onAck: (v) => set(() => ack = v)),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
// 관리자 3a 판단 (10/20): 확인한 쌍인데 옵션이 바뀌어 지울 것이 늘어 다시 묻는 것
|
||||
if (!now && live.reasked.contains(LiveSync.keyOf(x))) ...[
|
||||
Text(tr('지우기를 확인한 뒤 rsync 옵션이 바뀌어 지울 것이 늘었습니다. 다시 확인해 주세요.'),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.orangeAccent)),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
Text(trf('"{0}" 에는 없고 대상 "{1}" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).',
|
||||
[vDisplay(x.source), vDisplay(x.target)])),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: ListView(children: [
|
||||
if (list.isEmpty)
|
||||
Text(tr('미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다'),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
for (final n in plain.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')),
|
||||
if (plain.length > 500) Text(trf('… 외 {0}개', [plain.length - 500])),
|
||||
if (filtered.isNotEmpty) ...[
|
||||
if (plain.isNotEmpty) const SizedBox(height: 10),
|
||||
Text(keepOptionsTogether(trf('거른 것 {0}개도 지워집니다 (--delete-excluded)', [filtered.length])),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, color: JjColors.danger)),
|
||||
Text(keepOptionsTogether(tr('rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.')),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
const SizedBox(height: 4),
|
||||
for (final n in filtered.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas', color: JjColors.danger)),
|
||||
if (filtered.length > 500) Text(trf('… 외 {0}개', [filtered.length - 500])),
|
||||
],
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('나중에'))),
|
||||
if (!now) TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('지우지 않기 (지우기 끄기)'))),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: JjColors.danger),
|
||||
onPressed: limits.isNotEmpty && !ack ? null : () => Navigator.pop(ctx, true),
|
||||
child: Text(list.isEmpty ? tr('지우고 맞추기') : trf('{0}개 지우고 맞추기', [list.length])),
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(trf('대상에서 {0}개를 지울까요?', [list.length])),
|
||||
content: SizedBox(
|
||||
width: 560,
|
||||
height: 360,
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(trf('"{0}" 에는 없고 대상 "{1}" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).',
|
||||
[vDisplay(x.source), vDisplay(x.target)])),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: ListView(children: [
|
||||
for (final n in list.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')),
|
||||
if (list.length > 500) Text(trf('… 외 {0}개', [list.length - 500])),
|
||||
]),
|
||||
),
|
||||
],
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('나중에'))),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('지우지 않기 (지우기 끄기)'))),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: JjColors.danger),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(trf('{0}개 지우고 맞추기', [list.length])),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (pick == null) return;
|
||||
await live.decideDelete(x, delete: pick);
|
||||
}
|
||||
|
||||
/// 70: 원본이 비어 멈춘 쌍 - 지워질 목록을 보여 주고 [그래도 맞추기] 로 확인받는다 (42 와 같은 창)
|
||||
|
|
@ -647,7 +600,6 @@ class _CopyCardState extends State<_CopyCard> {
|
|||
width: 320,
|
||||
child: TextField(
|
||||
controller: _options,
|
||||
inputFormatters: const [OptionTextFormatter()],
|
||||
enabled: _method != 'builtin',
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
||||
decoration: InputDecoration(isDense: true, border: const OutlineInputBorder(), labelText: tr('옵션'), hintText: optsHint),
|
||||
|
|
@ -662,8 +614,6 @@ class _CopyCardState extends State<_CopyCard> {
|
|||
'${t.move ? tr('이동') : tr('복사')} · ${trf('실제 실행: {0}', [effectiveRunText(_method, _options.text.trim(), move: t.move, skipNewer: s.copySkipNewer)])}',
|
||||
style: TextStyle(fontSize: 12, color: t.move ? Colors.orangeAccent : JjColors.textDim),
|
||||
),
|
||||
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션 (실행은 작업이 막는다)
|
||||
if (_method == 'rsync' && optionProblems(_options.text).isNotEmpty) OptionProblemNote(optionProblems(_options.text)),
|
||||
if (!t.move && optionsRemoveSource(_options.text))
|
||||
Text(tr('복사 작업이라 칸의 원본 지우기 옵션 (--remove-source-files · /MOV) 은 쓰지 않습니다. 옮기려면 Rsync 화면에서 [원본 파일 지우기] 를 켜세요'),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
|
||||
|
|
@ -698,8 +648,7 @@ class _CopyCardState extends State<_CopyCard> {
|
|||
TextButton.icon(onPressed: () => center.cancel(t.id), icon: const Icon(Icons.stop, size: 18), label: Text(tr('취소')))
|
||||
else
|
||||
FilledButton.icon(
|
||||
// 관리자 지시 (10/20, 28 지적): 옵션이 막히면 (빨간 안내가 보이면) 확인 창처럼 [실행] 도 끈다 - 누르면 실패로 끝났다
|
||||
onPressed: _method == 'rsync' && optionProblems(_options.text).isNotEmpty ? null : () => _runPressed(t),
|
||||
onPressed: () => _runPressed(t),
|
||||
icon: const Icon(Icons.play_arrow, size: 18),
|
||||
label: Text(t.lastResult == 'cancelled' || t.lastResult == 'failed' ? tr('재개') : tr('실행')),
|
||||
),
|
||||
|
|
@ -973,18 +922,6 @@ class _LiveTabState extends State<_LiveTab> {
|
|||
final active = LiveSync.activeNow(x);
|
||||
final running = live?.isRunning(x) ?? false;
|
||||
final held = live?.isPaused(x) ?? false;
|
||||
// 257: 칩은 길게 누르기 · 마우스로 전체를 본다. 관리자 지시 (10/20, 28 지적): 좁은 화면 (폰 세로) 에서는 경로 줄에 끼면
|
||||
// "rsync · 복…" 으로 잘려 경로 아래 따로 한 줄로 (넓으면 예전처럼 경로 옆)
|
||||
final narrow = MediaQuery.sizeOf(context).width < 600;
|
||||
final chipText = [tr(CopyMethod.of(x.method).label), tr('복사로 맞춤'), if (x.delete) tr('지우기 포함')].join(' · ');
|
||||
final chip = Tooltip(
|
||||
message: chipText,
|
||||
child: Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
// 243: 실시간 동기화는 늘 복사로 맞춘다 (원본은 지우지 않음 - 원본을 비우려면 [최종 정리])
|
||||
label: Text(chipText, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
);
|
||||
return Card(
|
||||
color: JjColors.panel,
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
|
|
@ -1022,7 +959,17 @@ class _LiveTabState extends State<_LiveTab> {
|
|||
),
|
||||
),
|
||||
// 257: 폰 세로에서 이 줄이 넘쳤다 - 칩은 남는 폭만큼 (넘치면 … 로 줄임, 전체는 길게 누르기 · 마우스)
|
||||
if (!narrow) Flexible(child: chip),
|
||||
Flexible(
|
||||
child: Tooltip(
|
||||
message: [tr(CopyMethod.of(x.method).label), tr('복사로 맞춤'), if (x.delete) tr('지우기 포함')].join(' · '),
|
||||
child: Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
// 243: 실시간 동기화는 늘 복사로 맞춘다 (원본은 지우지 않음 - 원본을 비우려면 [최종 정리])
|
||||
label: Text([tr(CopyMethod.of(x.method).label), tr('복사로 맞춤'), if (x.delete) tr('지우기 포함')].join(' · '),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 이번 실행 동안 멈춤 / 시작 (설정의 켜기 · 끄기는 오른쪽 스위치)
|
||||
if (live != null && x.enabled)
|
||||
IconButton(
|
||||
|
|
@ -1032,7 +979,6 @@ class _LiveTabState extends State<_LiveTab> {
|
|||
),
|
||||
Switch(value: x.enabled, onChanged: (v) => _replace(x, x.copyWith(enabled: v))),
|
||||
]),
|
||||
if (narrow) Padding(padding: const EdgeInsets.only(left: 32, top: 2), child: chip),
|
||||
if (held)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 32, top: 2),
|
||||
|
|
@ -1053,21 +999,6 @@ class _LiveTabState extends State<_LiveTab> {
|
|||
),
|
||||
]),
|
||||
),
|
||||
// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙 → 저절로 지우지 않고 보내기만. 지우기는 확인을 거쳐 한 번씩
|
||||
if (live != null && live.noAutoDelete[k] != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 32, top: 4),
|
||||
child: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
|
||||
const Icon(Icons.block, size: 16, color: Colors.redAccent),
|
||||
Text(tr('이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다'),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)),
|
||||
Text('(${live.noAutoDelete[k]!.map(unbreakableOption).join(' · ')})', style: const TextStyle(fontSize: 12, color: Colors.redAccent)),
|
||||
OutlinedButton(
|
||||
onPressed: () => confirmLiveDeleteNow(context, live, x),
|
||||
child: Text(tr('지금 지우기 (확인)')),
|
||||
),
|
||||
]),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 32, top: 4),
|
||||
child: Wrap(spacing: 16, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../core/vfs.dart';
|
||||
import '../l10n/tr.dart';
|
||||
|
||||
|
|
@ -27,33 +25,3 @@ String shortPath(String path, {int keep = 3}) {
|
|||
final parts = friendlyPath(path).split(' › ');
|
||||
return parts.length <= keep ? parts.join(' › ') : ['…', ...parts.sublist(parts.length - keep)].join(' › ');
|
||||
}
|
||||
|
||||
/// [text] 를 [style] 로 한 줄에 그렸을 때의 폭
|
||||
double textWidth(BuildContext context, String text, TextStyle style) {
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(text: text, style: DefaultTextStyle.of(context).style.merge(style)),
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
maxLines: 1)
|
||||
..layout();
|
||||
final w = tp.width;
|
||||
tp.dispose();
|
||||
return w;
|
||||
}
|
||||
|
||||
/// [text] 가 [maxWidth] 를 넘으면 가운데를 "…" 로 줄인다 (앞 · 끝이 보이게 - 412 캡처 10/18, 갈 곳 막대)
|
||||
String middleEllipsis(BuildContext context, String text, TextStyle style, double maxWidth) {
|
||||
if (maxWidth <= 0) return '…';
|
||||
if (textWidth(context, text, style) <= maxWidth) return text;
|
||||
var lo = 0, hi = text.length;
|
||||
String cut(int n) => '${text.substring(0, (n + 1) ~/ 2)}…${text.substring(text.length - n ~/ 2)}';
|
||||
while (lo < hi) {
|
||||
final mid = (lo + hi + 1) ~/ 2;
|
||||
if (textWidth(context, cut(mid), style) <= maxWidth) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
return lo == 0 ? '…' : cut(lo);
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue