From 08c5fcd2e9eceec46f5d215d98d2860d55e779fe Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:43:35 +0900 Subject: [PATCH] =?UTF-8?q?v2026.10.01=5F001:=20=EC=A7=80=EB=82=9C=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EB=B9=84=EC=A0=95=EC=83=81=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=EC=95=8C=EB=A6=BC,=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=ED=8C=8C=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지난 실행이 정상으로 끝나지 않았으면 켤 때 알림 (session.json, 30초마다 살아 있는 시각 기록) - 작업 기록을 설정 폴더의 app.log 에도 저장 (2MB 넘으면 app.log.1) - 다운로드 시작 · 완료 · 실패를 작업 기록에 남김 Co-Authored-By: Claude Opus 5.5 --- lib/app/app_controller.dart | 15 ++++ lib/app/download_manager.dart | 19 +++++ lib/main.dart | 24 +++++- lib/platform/windows/session_marker.dart | 58 ++++++++++++++ pubspec.yaml | 2 +- test/session_test.dart | 97 ++++++++++++++++++++++++ 6 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 lib/platform/windows/session_marker.dart create mode 100644 test/session_test.dart diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index e72bac3..6013f27 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -1103,11 +1103,26 @@ class AppController extends ChangeNotifier { /// 작업 기록에 한 줄 남기기 (앱의 다른 부분에서 알릴 것이 있을 때) void note(String msg) => _log(msg); + /// 작업 기록을 파일에도 남길 곳 (설정 폴더의 app.log). 프로그램이 갑자기 꺼져도 무슨 일이 있었는지 볼 수 있게. + /// 2MB 를 넘으면 app.log.1 로 옮기고 새로 시작한다. + String? logFile; + void _log(String msg) { final t = DateTime.now(); String two(int n) => n.toString().padLeft(2, '0'); logs.add('[${two(t.hour)}:${two(t.minute)}:${two(t.second)}] $msg'); if (logs.length > 500) logs.removeAt(0); + _appendLogFile('${t.year}-${two(t.month)}-${two(t.day)} ${two(t.hour)}:${two(t.minute)}:${two(t.second)} $msg'); notifyListeners(); } + + void _appendLogFile(String line) { + final path = logFile; + if (path == null) return; + try { + final f = File(path); + if (f.existsSync() && f.lengthSync() > 2 * 1024 * 1024) f.renameSync('$path.1'); + f.writeAsStringSync('$line\n', mode: FileMode.append, flush: true); + } catch (_) {} + } } diff --git a/lib/app/download_manager.dart b/lib/app/download_manager.dart index 2aa9fd9..59da3ce 100644 --- a/lib/app/download_manager.dart +++ b/lib/app/download_manager.dart @@ -208,9 +208,28 @@ class DownloadManager extends ChangeNotifier { } /// 상태가 바뀔 때: 화면 갱신 + 대기 중인 다음 작업 시작 + /// 작업 기록에 남기기 (앱이 연결해 준다): 다운로드 시작 · 완료 · 실패 + void Function(String msg)? log; + final Map _lastState = {}; + void _changed() { for (final t in tasks) { if (t.state == DownloadState.done && _reported.add(t.id)) _finished.add(t); + final before = _lastState[t.id]; + if (before != t.state) { + _lastState[t.id] = t.state; + final name = t.title == t.source ? t.source : '${t.title} (${t.source})'; + switch (t.state) { + case DownloadState.downloading when before != DownloadState.downloading: + log?.call('다운로드 시작: $name'); + case DownloadState.done: + log?.call('다운로드 완료: $name${t.totalBytes == null ? '' : ' (${formatBytes(t.totalBytes!)})'}'); + case DownloadState.failed: + log?.call('다운로드 실패: $name - ${t.error ?? ''}'); + default: + break; + } + } } notifyListeners(); _pump(); diff --git a/lib/main.dart b/lib/main.dart index 5518485..aa27043 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,7 @@ import 'core/playlist.dart'; import 'platform/windows/com_guard.dart'; import 'platform/windows/desktop_shell.dart'; import 'platform/windows/exit_trace.dart'; +import 'platform/windows/session_marker.dart'; import 'platform/windows/single_instance.dart'; import 'platform/windows/window_memory.dart'; import 'services/platform_services.dart'; @@ -58,7 +59,17 @@ Future main(List args) async { } final services = PlatformServices.create(); - final controller = AppController(services, settingsStore: SettingsStore()); + final controller = AppController(services, settingsStore: SettingsStore()) + ..logFile = p.join(dataDir, 'app.log'); + // 지난 실행이 정상으로 끝났는지 (아니면 알려 주고 기록에 남긴다) + final session = SessionMarker(p.join(dataDir, 'session.json')); + final crashed = session.start(); + controller.note('── 시작 $appTitle ──'); + if (crashed != null) { + String hm(DateTime t) => '${t.month}/${t.day} ${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + controller.note('⚠ 지난 실행이 정상적으로 끝나지 않았습니다 (시작 ${hm(crashed.$1)}, 마지막 확인 ${hm(crashed.$2)}). ' + '그 전의 기록은 설정 폴더의 app.log 에 있습니다.'); + } await controller.init(); final bookmarks = BookmarksController(); @@ -120,6 +131,8 @@ Future main(List args) async { ExitStep('동영상 목록 · 창 위치 저장', () async { controller.stopVideoListShare(); await memory.save(); + controller.note('── 정상 종료 ──'); + session.markClean(); }), ExitStep('창 종료', () async {}), ]; @@ -191,6 +204,7 @@ Future main(List args) async { } }); downloads.addToEditList = controller.addDownloaded; + downloads.log = controller.note; unawaited(downloads.startClipboardWatch()); // 탐색기 메뉴 · 두 번째 실행에서 온 요청 처리 @@ -240,6 +254,14 @@ Future main(List args) async { WidgetsBinding.instance.addPostFrameCallback((_) async { final ctx = navigatorKey.currentContext; if (ctx == null) return; + if (crashed != null) { + messengerKey.currentState?.showSnackBar(SnackBar( + duration: const Duration(seconds: 10), + content: Text('지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 ' + '${crashed.$2.hour.toString().padLeft(2, '0')}:${crashed.$2.minute.toString().padLeft(2, '0')}). ' + '작업 기록에 남겨 두었습니다.'), + )); + } // 시작 화면을 "웹 브라우저" 로 정했으면 브라우저를 연다 (MKV 화면은 그 아래에 있음) if (controller.settings.startScreen == 'browser') { navigatorKey.currentState?.push(MaterialPageRoute( diff --git a/lib/platform/windows/session_marker.dart b/lib/platform/windows/session_marker.dart new file mode 100644 index 0000000..3c601d6 --- /dev/null +++ b/lib/platform/windows/session_marker.dart @@ -0,0 +1,58 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +/// 지난 실행이 정상으로 끝났는지 알아내는 표시 (설정 폴더의 session.json). +/// +/// 켤 때 "실행 중" 으로 적고 30초마다 살아 있는 시각을 고쳐 적는다. 종료 버튼 · 트레이 종료로 끝나면 +/// "정상 종료" 로 바꾼다. 다음에 켤 때 "실행 중" 인 채로 남아 있으면 지난번은 비정상으로 끝난 것이다 +/// (프로그램 오류 · 강제 종료 · 전원 꺼짐 등). +class SessionMarker { + final String file; + Timer? _beat; + DateTime _start = DateTime.now(); + + SessionMarker(this.file); + + /// 지난 실행이 비정상으로 끝났으면 (시작 시각, 마지막으로 살아 있던 시각), 아니면 null. + /// 그리고 이번 실행을 "실행 중" 으로 적기 시작한다. + (DateTime, DateTime)? start() { + (DateTime, DateTime)? crashed; + try { + final f = File(file); + if (f.existsSync()) { + final j = jsonDecode(f.readAsStringSync()) as Map; + if (j['clean'] != true) { + final s = DateTime.tryParse(j['start'] as String? ?? ''); + final seen = DateTime.tryParse(j['lastSeen'] as String? ?? ''); + if (s != null && seen != null) crashed = (s, seen); + } + } + } catch (_) {} + _start = DateTime.now(); + _write(clean: false); + _beat?.cancel(); + _beat = Timer.periodic(const Duration(seconds: 30), (_) => _write(clean: false)); + return crashed; + } + + /// 정상 종료로 표시 (종료 진행 창의 저장 단계에서) + void markClean() { + _beat?.cancel(); + _beat = null; + _write(clean: true); + } + + void _write({required bool clean}) { + try { + final tmp = File('$file.$pid.tmp') + ..writeAsStringSync(jsonEncode({ + 'pid': pid, + 'start': _start.toIso8601String(), + 'lastSeen': DateTime.now().toIso8601String(), + 'clean': clean, + }), flush: true); + tmp.renameSync(file); + } catch (_) {} + } +} diff --git a/pubspec.yaml b/pubspec.yaml index a3761c7..0b4401d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2026.9.30+2 +version: 2026.10.1+1 environment: sdk: ^3.13.4 diff --git a/test/session_test.dart b/test/session_test.dart new file mode 100644 index 0000000..83ba7fd --- /dev/null +++ b/test/session_test.dart @@ -0,0 +1,97 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/download_manager.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/download_detect.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/platform/windows/session_marker.dart'; +import 'package:jj_mkvmaker/services/downloader.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:path/path.dart' as p; + +class _Backend implements DownloadBackend { + void Function()? changed; + @override + DownloadKind get kind => DownloadKind.video; + @override + Future<(String, List)?> expandPlaylist(String url) async => null; + @override + Future start(DownloadTask t, void Function() changed) async { + this.changed = changed; + t.state = DownloadState.downloading; + changed(); + } + + @override + Future pause(DownloadTask t) async {} + @override + Future cancel(DownloadTask t) async {} + @override + Future shutdown() async {} +} + +void main() { + test('지난 실행: 정상 종료면 조용히, "실행 중" 으로 남아 있으면 비정상 종료로 알린다', () { + final dir = Directory.systemTemp.createTempSync('jj_session_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final file = p.join(dir.path, 'session.json'); + + expect(SessionMarker(file).start(), isNull); // 처음 + // 그대로 끝남 (markClean 없이) → 다음에 켤 때 알림 + final crashed = SessionMarker(file).start(); + expect(crashed, isNotNull); + expect(crashed!.$2.isAfter(crashed.$1) || crashed.$2 == crashed.$1, isTrue); + + final s = SessionMarker(file); + s.start(); + s.markClean(); // 종료 버튼으로 끝남 + expect(SessionMarker(file).start(), isNull); + }); + + test('작업 기록 파일: 날짜 · 시각과 함께 남고, 2MB 가 넘으면 app.log.1 로 넘긴다', () { + final dir = Directory.systemTemp.createTempSync('jj_applog_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final file = p.join(dir.path, 'app.log'); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())) + ..logFile = file; + c.note('첫 줄'); + c.note('둘째 줄'); + final lines = File(file).readAsLinesSync(); + expect(lines, hasLength(2)); + expect(lines.first, matches(RegExp(r'^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d 첫 줄$'))); + + File(file).writeAsStringSync('x' * (2 * 1024 * 1024 + 10)); + c.note('새 파일'); + expect(File('$file.1').existsSync(), isTrue); + expect(File(file).readAsLinesSync().single, endsWith('새 파일')); + }); + + test('다운로드 시작 · 완료 · 실패를 작업 기록에 남긴다', () async { + final b = _Backend(); + final d = DownloadManager(backends: [b], settings: () => AppSettings(), readClipboard: () async => null); + final logged = []; + d.log = logged.add; + final t = d.addPage('https://vimeo.com/1')!..title = '영상'; + await Future.delayed(Duration.zero); + t + ..state = DownloadState.done + ..totalBytes = 5 << 20; + b.changed!(); + final t2 = d.addPage('https://vimeo.com/2')!..title = '두 번째'; + await Future.delayed(Duration.zero); + t2 + ..state = DownloadState.failed + ..error = '볼 수 없는 영상입니다'; + b.changed!(); + expect(logged, [ + '다운로드 시작: https://vimeo.com/1', // 시작할 때는 아직 제목을 모름 + '다운로드 완료: 영상 (https://vimeo.com/1) (5.0MB)', + '다운로드 시작: https://vimeo.com/2', + '다운로드 실패: 두 번째 (https://vimeo.com/2) - 볼 수 없는 영상입니다', + ]); + d.dispose(); + }); +}