253 lines
10 KiB
Dart
253 lines
10 KiB
Dart
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<String, dynamic> 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<String, Map<String, dynamic>> components;
|
|
const ComponentManifest(this.components);
|
|
|
|
factory ComponentManifest.parse(String text) {
|
|
final j = jsonDecode(text) as Map<String, dynamic>;
|
|
final c = (j['components'] as Map<String, dynamic>? ?? const {});
|
|
return ComponentManifest({for (final e in c.entries) e.key: e.value as Map<String, dynamic>});
|
|
}
|
|
|
|
/// 이 기기 ([platform]) 에서 그 컴포넌트를 설치할 때 받을 파일들 (없으면 null = 이 기기에서는 못 씀)
|
|
List<ComponentFile>? 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, dynamic>)];
|
|
}
|
|
|
|
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<ComponentManifest> 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<void> 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<void> remove(String id) async {
|
|
final dir = dirOf(id);
|
|
if (await dir.exists()) await dir.delete(recursive: true);
|
|
}
|
|
|
|
Future<void> _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<Digest>();
|
|
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<void> _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<String, String>? _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<void> _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<String> 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<T> implements Sink<T> {
|
|
final events = <T>[];
|
|
@override
|
|
void add(T event) => events.add(event);
|
|
@override
|
|
void close() {}
|
|
}
|