import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import '../l10n/tr.dart'; import 'component_store.dart'; /// Tor 컴포넌트 (환경 설정 > 컴포넌트 > Tor 브라우저). /// - Windows: 공식 Tor Expert Bundle 을 받아 (판 · SHA-256 고정) 앱 데이터 폴더 components/tor 에 tor.exe · geoip 를 둔다 /// - Android: 받은 실행 파일은 실행할 수 없어 (W^X) 같은 판의 Android 번들에서 꺼낸 libTor.so (실행 파일) 를 APK 에 넣었다 /// (tool/fetch_tor_android.ps1 → third_party/tor/android/arm64-v8a/libtor.so). 켜기 · 끄기만 한다. class TorBundle { /// Tor Browser 15.0.24 의 Expert Bundle (tor 0.4.9.13). 올릴 때: 판 · 아래 SHA-256 (공식 sha256sums-signed-build.txt) · /// tool/fetch_tor_android.ps1 · tool/licenses/fetch_licenses.py 를 함께 바꾼다 static const version = '15.0.24'; static const torVersion = '0.4.9.13'; static const windowsFile = 'tor-expert-bundle-windows-x86_64-$version.tar.gz'; static const windowsSha256 = 'e9dc6ccc93cd6afa507193f4de284d6424233ff5102155cd2c94b259e8a22b65'; static const androidFile = 'tor-expert-bundle-android-aarch64-$version.tar.gz'; static const androidSha256 = '2a7a02484149b05f64b582a4a1e8eb8aabb9f6cd32daca4e03c58e09839bb7a8'; /// 받을 곳: 공식 보관소 (판이 지워지지 않음), 안 되면 공식 배포처 static List urls(String file) => [ 'https://archive.torproject.org/tor-package-archive/torbrowser/$version/$file', 'https://dist.torproject.org/torbrowser/$version/$file', ]; /// 받을 크기 (안내용) static const approxDownloadMb = 22; static const id = 'tor'; static Directory get dir => ComponentStore.shared.dirOf(id); /// Windows: 설치한 tor.exe (없으면 null) static String? get windowsExe { final f = File(p.join(dir.path, 'tor.exe')); return f.existsSync() && ComponentStore.shared.isInstalled(id) ? f.path : null; } /// 설치한 판 (installed.json) static String? installedVersion() { try { final j = jsonDecode(File(p.join(dir.path, 'installed.json')).readAsStringSync()) as Map; return j['version'] as String?; } catch (_) { return null; } } /// Android: APK 에 들어 있는 tor (libtor.so). 없으면 (이 APK 에 넣지 않음) null static Future androidExe() async { try { final d = await const MethodChannel('jj_mkvmaker/android').invokeMethod('nativeLibDir'); final f = d == null ? null : File(p.join(d, 'libtor.so')); return f != null && f.existsSync() ? f.path : null; } catch (_) { return null; } } /// 이 기기에서 쓸 tor 실행 파일 static Future executable() async => Platform.isAndroid ? androidExe() : windowsExe; /// geoip 파일 (있으면 tor 에 알려 준다 - 없어도 동작) static String? geoip(String name) { final f = File(p.join(dir.path, name)); return f.existsSync() ? f.path : null; } /// Windows 설치: 받기 (SHA-256 확인) → 풀기 → tor.exe · geoip · 라이선스 문서만 두기. /// [onProgress] (단계, 0~1 또는 null). [urlsOverride] · [sha256Override] 는 시험용 static Future installWindows({ void Function(String step, double? progress)? onProgress, bool Function()? isCancelled, List? urlsOverride, String? sha256Override, String? targetOverride, }) async { final target = Directory(targetOverride ?? dir.path); final work = await Directory.systemTemp.createTemp('jj_tor_'); final client = HttpClient()..userAgent = 'JJMKVMaker'; try { final archive = File(p.join(work.path, windowsFile)); Object? last; var ok = false; for (final url in urlsOverride ?? urls(windowsFile)) { try { await _download(client, url, archive, sha256Override ?? windowsSha256, (d) => onProgress?.call(trf('받는 중: {0}', [windowsFile]), d), isCancelled); ok = true; break; } on _Cancelled { rethrow; } on ComponentException { rethrow; // 손상 (SHA-256 다름) 은 다른 곳에서 받아도 다시 시도하지 않는다 } catch (e) { last = e; } } if (!ok) throw ComponentException(trf('Tor 를 받을 수 없습니다: {0}', [last])); onProgress?.call(tr('푸는 중'), null); final x = Directory(p.join(work.path, 'x'))..createSync(); final r = await Process.run('tar', ['-xzf', archive.path, '-C', x.path]); if (r.exitCode != 0) throw ComponentException(trf('압축을 풀 수 없습니다: {0}', [r.stderr])); final exe = File(p.join(x.path, 'tor', 'tor.exe')); if (!exe.existsSync()) throw ComponentException(tr('받은 파일에 tor.exe 가 없습니다')); // 새 폴더에 모은 뒤 바꿔 넣는다 (중간에 실패해도 반쯤 깔린 것이 남지 않게) final staging = Directory('${target.path}.new'); if (staging.existsSync()) staging.deleteSync(recursive: true); Directory(p.join(staging.path, 'docs')).createSync(recursive: true); exe.copySync(p.join(staging.path, 'tor.exe')); for (final g in ['geoip', 'geoip6']) { final f = File(p.join(x.path, 'data', g)); if (f.existsSync()) f.copySync(p.join(staging.path, g)); } final docs = Directory(p.join(x.path, 'docs')); if (docs.existsSync()) { for (final f in docs.listSync().whereType()) { f.copySync(p.join(staging.path, 'docs', p.basename(f.path))); } } File(p.join(staging.path, 'installed.json')).writeAsStringSync( jsonEncode({'version': version, 'tor': torVersion, 'sha256': sha256Override ?? windowsSha256, 'installed': DateTime.now().toIso8601String()})); if (target.existsSync()) target.deleteSync(recursive: true); target.parent.createSync(recursive: true); staging.renameSync(target.path); onProgress?.call(tr('완료'), 1); } on _Cancelled { throw ComponentException(tr('취소했습니다')); } finally { client.close(force: true); try { work.deleteSync(recursive: true); } catch (_) {} } } static Future _download(HttpClient client, String url, File out, String sha, void Function(double?) onProgress, bool Function()? isCancelled) async { final req = await client.getUrl(Uri.parse(url)); final res = await req.close(); if (res.statusCode != 200) throw HttpException(trf('받을 수 없습니다 ({0}): {1}', [res.statusCode, url])); final total = res.contentLength; final sink = out.openWrite(); final hash = AccumulatorSink(); final hasher = sha256.startChunkedConversion(hash); var got = 0; try { await for (final chunk in res) { if (isCancelled?.call() ?? false) throw const _Cancelled(); sink.add(chunk); hasher.add(chunk); got += chunk.length; onProgress(total > 0 ? got / total : null); } } finally { await sink.close(); hasher.close(); } if (hash.events.single.toString() != sha.toLowerCase()) { await out.delete(); throw ComponentException(trf('파일이 손상되었습니다 (SHA-256 다름): {0}', [p.basename(out.path)])); } } /// Windows 제거 (Tor 를 먼저 끈 뒤) static Future removeWindows() async { if (dir.existsSync()) await dir.delete(recursive: true); } } class _Cancelled implements Exception { const _Cancelled(); }