Compare commits

..

No commits in common. "기능추가1" and "secret_test" have entirely different histories.

171 changed files with 457 additions and 15227 deletions

View File

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

View File

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

View File

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

View File

@ -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 패키지 라이선스: 프로그램의 "환경 설정 > 라이선스" 화면

View File

@ -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 패키지: [패키지 라이선스]
[앱에 들어 있는 별도 실행 파일 · 라이브러리]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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, '이동'));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: '[나중에] - 지우지 않음');
});
}
}
}

View File

@ -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: '찍기만 - 지우지 않음');
});
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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: '찍기만 하고 지우지 않음');
});
}
}
}

View File

@ -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: '찍기만 - 실행하지 않음');
});
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 만들기를 사용할 수 없습니다.'));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 를 부르면 이미 끝난 흐름은 영영 끝나지 않았다)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;');
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);
}

View File

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

View File

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

View File

@ -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);
// 진행률 막대는 다운로드만 있을 때 (작업 진행률은 동영상마다 따로라 하나로 합치기 어렵다).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -107,7 +107,7 @@ class PlatformServices {
fallbackCookieArgs: () => ytDlpCookieArgs(
browser: internalBrowserCookies, internalCookieFile: s().internalCookieFile),
),
Aria2Backend(stallAfter: () => Duration(seconds: s().torrentStallSeconds)),
Aria2Backend(),
],
shell: DesktopShell(),
updater: GitHubUpdater(),

View File

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

View File

@ -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('번호 시작')),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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('닫기'))),
],
);
}),
);

View File

@ -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 서버 목록과 그 저장된 비밀번호도 지워집니다. 동영상 목록 · 즐겨찾기 · 파일은 그대로입니다')),
),
]),
),

View File

@ -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: [

View File

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