181 lines
7.5 KiB
Dart
181 lines
7.5 KiB
Dart
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<String> 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<String?> androidExe() async {
|
|
try {
|
|
final d = await const MethodChannel('jj_mkvmaker/android').invokeMethod<String>('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<String?> 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<void> installWindows({
|
|
void Function(String step, double? progress)? onProgress,
|
|
bool Function()? isCancelled,
|
|
List<String>? 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<File>()) {
|
|
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<void> _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<Digest>();
|
|
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<void> removeWindows() async {
|
|
if (dir.existsSync()) await dir.delete(recursive: true);
|
|
}
|
|
}
|
|
|
|
class _Cancelled implements Exception {
|
|
const _Cancelled();
|
|
}
|