import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:archive/archive_io.dart'; import 'package:crypto/crypto.dart'; import 'package:path/path.dart' as p; import '../core/app_update.dart' show updateRepo; /// 컴포넌트 목록 (GitHub 릴리스 "components" 의 components.json) 주소 const componentsManifestUrl = 'https://github.com/$updateRepo/releases/download/components/components.json'; /// 컴포넌트를 설치할 때 받는 파일 하나 class ComponentFile { final String name; final String url; /// 'msi' (Windows 설치 파일 → 관리 설치로 풀기) · 'zip' (풀기, 예: Java 런타임) · 'oxt' (LibreOffice 확장) · 'file' (그대로 둠) final String kind; final String? sha256; final int size; const ComponentFile({required this.name, required this.url, required this.kind, this.sha256, this.size = 0}); factory ComponentFile.fromJson(Map j) => ComponentFile( name: j['name'] as String? ?? '', url: j['url'] as String, kind: j['kind'] as String? ?? 'file', sha256: (j['sha256'] as String?)?.toLowerCase(), size: (j['size'] as num?)?.toInt() ?? 0, ); } /// components.json: { "format": 1, "components": { "(id)": { "version": "...", "windows": [파일...], "android": [...] } } } class ComponentManifest { final Map> components; const ComponentManifest(this.components); factory ComponentManifest.parse(String text) { final j = jsonDecode(text) as Map; final c = (j['components'] as Map? ?? const {}); return ComponentManifest({for (final e in c.entries) e.key: e.value as Map}); } /// 이 기기 ([platform]) 에서 그 컴포넌트를 설치할 때 받을 파일들 (없으면 null = 이 기기에서는 못 씀) List? files(String id, String platform) { final list = components[id]?[platform] as List?; return list == null ? null : [for (final x in list) ComponentFile.fromJson(x as Map)]; } String version(String id) => components[id]?['version'] as String? ?? ''; } class ComponentException implements Exception { final String message; const ComponentException(this.message); @override String toString() => message; } /// 컴포넌트 설치 (받기 · 확인 · 풀기) · 제거, 그리고 설치한 도구 쓰기 (문서 → PDF). /// 앱 데이터 폴더의 components/(id) 아래에 둔다 (앱을 업데이트해도 지워지지 않게). class ComponentStore { final String dataDir; final String manifestUrl; final HttpClient _http = HttpClient()..userAgent = 'JJMKVMaker'; bool _cancel = false; ComponentStore(this.dataDir, {this.manifestUrl = componentsManifestUrl}); static ComponentStore? _shared; /// 앱의 컴포넌트 저장소 (앱 데이터 폴더). 시험에서는 [ComponentStore.shared] 를 바꿔 쓴다. static ComponentStore get shared => _shared ??= ComponentStore(_dataDir ?? Directory.systemTemp.path); static set shared(ComponentStore s) => _shared = s; /// main 에서 정함 (앱 데이터 폴더) static String? _dataDir; static set dataDirectory(String d) => _dataDir = d; static String get platform => Platform.isWindows ? 'windows' : Platform.isAndroid ? 'android' : 'other'; Directory dirOf(String id) => Directory(p.join(dataDir, 'components', id)); File _mark(String id) => File(p.join(dirOf(id).path, 'installed.json')); bool isInstalled(String id) => _mark(id).existsSync(); void cancel() => _cancel = true; Future manifest() async { final req = await _http.getUrl(Uri.parse(manifestUrl)); final res = await req.close(); final body = await res.transform(utf8.decoder).join(); if (res.statusCode != 200) throw ComponentException('컴포넌트 목록을 받을 수 없습니다 (${res.statusCode})'); return ComponentManifest.parse(body); } /// 설치: 목록의 파일을 받아 (SHA-256 확인) 풀고 설치 표시를 남긴다. /// [onProgress] (지금 하는 일, 0~1 또는 모르면 null) Future install(String id, {void Function(String step, double? progress)? onProgress}) async { _cancel = false; final m = await manifest(); final files = m.files(id, platform); if (files == null) throw const ComponentException('이 기기에서는 쓸 수 없는 컴포넌트입니다'); final dir = dirOf(id); final dl = Directory(p.join(dir.path, '_download')); await dl.create(recursive: true); try { final got = <(ComponentFile, File)>[]; for (final f in files) { final out = File(p.join(dl.path, f.name.isEmpty ? p.basename(Uri.parse(f.url).path) : f.name)); await _download(f, out, (d) => onProgress?.call('받는 중: ${f.name}', d)); got.add((f, out)); } for (final (f, file) in got) { if (_cancel) throw const ComponentException('취소했습니다'); switch (f.kind) { case 'msi': onProgress?.call('푸는 중: ${f.name}', null); await _runMsiAdmin(file.path, p.join(dir.path, 'app')); case 'zip': onProgress?.call('푸는 중: ${f.name}', null); await extractFileToDisk(file.path, p.join(dir.path, 'jre')); case 'oxt': onProgress?.call('확장 설치: ${f.name}', null); await _addExtension(id, file.path); default: await file.copy(p.join(dir.path, p.basename(file.path))); } } await _mark(id).writeAsString(jsonEncode({'version': m.version(id), 'installed': DateTime.now().toIso8601String()})); } finally { try { await dl.delete(recursive: true); } catch (_) {} } } Future remove(String id) async { final dir = dirOf(id); if (await dir.exists()) await dir.delete(recursive: true); } Future _download(ComponentFile f, File out, void Function(double? progress) onProgress) async { final req = await _http.getUrl(Uri.parse(f.url)); final res = await req.close(); if (res.statusCode != 200) throw ComponentException('받을 수 없습니다 (${res.statusCode}): ${f.url}'); final total = res.contentLength > 0 ? res.contentLength : f.size; final sink = out.openWrite(); final hash = AccumulatorSink(); final hasher = sha256.startChunkedConversion(hash); var done = 0; try { await for (final chunk in res) { if (_cancel) throw const ComponentException('취소했습니다'); sink.add(chunk); hasher.add(chunk); done += chunk.length; onProgress(total > 0 ? done / total : null); } } finally { await sink.close(); hasher.close(); } final sum = hash.events.single.toString(); if (f.sha256 != null && f.sha256!.isNotEmpty && sum != f.sha256) { await out.delete(); throw ComponentException('파일이 손상되었습니다 (SHA-256 다름): ${f.name}'); } } /// Windows 설치 파일 (MSI) 을 관리 설치 (/a) 로 풀기: 관리자 권한 없이 폴더에 프로그램 파일만 Future _runMsiAdmin(String msi, String target) async { await Directory(target).create(recursive: true); final r = await Process.run('msiexec', ['/a', msi, '/qn', 'TARGETDIR=$target']); if (r.exitCode != 0) throw ComponentException('설치 파일을 풀 수 없습니다 (msiexec ${r.exitCode})'); } // ───────── 문서 미리보기 (LibreOffice) ───────── /// 설치한 LibreOffice 의 soffice.exe (없으면 null) String? soffice([String id = 'docs']) { final root = Directory(p.join(dirOf(id).path, 'app')); if (!root.existsSync()) return null; for (final e in root.listSync(recursive: true, followLinks: false)) { if (e is File && p.basename(e.path).toLowerCase() == 'soffice.exe' && p.basename(p.dirname(e.path)) == 'program') { return e.path; } } return null; } /// 함께 받은 Java 런타임 (HWP 확장 H2Orestart 가 Java 로 동작) 의 폴더 (bin/java.exe 가 있는 곳) String? javaHome([String id = 'docs']) { final root = Directory(p.join(dirOf(id).path, 'jre')); if (!root.existsSync()) return null; for (final e in root.listSync(recursive: true, followLinks: false)) { if (e is File && p.basename(e.path).toLowerCase() == 'java.exe' && p.basename(p.dirname(e.path)) == 'bin') { return p.dirname(p.dirname(e.path)); } } return null; } /// LibreOffice 가 쓸 환경: 받은 Java (JAVA_HOME) Map? _env(String id) { final jh = javaHome(id); return jh == null ? null : {'JAVA_HOME': jh, 'PATH': '${p.join(jh, 'bin')};${Platform.environment['PATH'] ?? ''}'}; } /// LibreOffice 사용자 설정 폴더 (앱 전용, 확장도 여기에) String _profileUrl(String id) => Uri.directory(p.join(dirOf(id).path, 'profile')).toString(); Future _addExtension(String id, String oxt) async { final office = soffice(id); if (office == null) throw const ComponentException('LibreOffice 를 먼저 설치해야 합니다'); final unopkg = p.join(p.dirname(office), 'unopkg.com'); final r = await Process.run(unopkg, ['add', '--suppress-license', '-env:UserInstallation=${_profileUrl(id)}', oxt], environment: _env(id)); if (r.exitCode != 0) throw ComponentException('확장을 설치할 수 없습니다 (${r.exitCode}): ${r.stderr}'); } /// 문서를 PDF 로 ([outDir] 에 만든 PDF 경로) Future convertToPdf(String input, String outDir, {String id = 'docs'}) async { final office = soffice(id); if (office == null) throw const ComponentException('문서 미리보기 컴포넌트를 먼저 설치하세요'); await Directory(outDir).create(recursive: true); final r = await Process.run(office, [ '--headless', '--norestore', '--nologo', '--nodefault', '-env:UserInstallation=${_profileUrl(id)}', '--convert-to', 'pdf', '--outdir', outDir, input, ], environment: _env(id)).timeout(const Duration(minutes: 3)); final out = p.join(outDir, '${p.basenameWithoutExtension(input)}.pdf'); if (!File(out).existsSync()) { throw ComponentException('PDF 로 바꾸지 못했습니다 (${r.exitCode}) ${'${r.stderr}'.trim()}'); } return out; } } /// sha256 의 startChunkedConversion 결과를 받는 그릇 class AccumulatorSink implements Sink { final events = []; @override void add(T event) => events.add(event); @override void close() {} }