v2026.10.01_001: 지난 실행 비정상 종료 알림, 작업 기록 파일
- 지난 실행이 정상으로 끝나지 않았으면 켤 때 알림 (session.json, 30초마다 살아 있는 시각 기록) - 작업 기록을 설정 폴더의 app.log 에도 저장 (2MB 넘으면 app.log.1) - 다운로드 시작 · 완료 · 실패를 작업 기록에 남김 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main v2026.10.01_001
parent
996dd44cb5
commit
08c5fcd2e9
|
|
@ -1103,11 +1103,26 @@ class AppController extends ChangeNotifier {
|
||||||
/// 작업 기록에 한 줄 남기기 (앱의 다른 부분에서 알릴 것이 있을 때)
|
/// 작업 기록에 한 줄 남기기 (앱의 다른 부분에서 알릴 것이 있을 때)
|
||||||
void note(String msg) => _log(msg);
|
void note(String msg) => _log(msg);
|
||||||
|
|
||||||
|
/// 작업 기록을 파일에도 남길 곳 (설정 폴더의 app.log). 프로그램이 갑자기 꺼져도 무슨 일이 있었는지 볼 수 있게.
|
||||||
|
/// 2MB 를 넘으면 app.log.1 로 옮기고 새로 시작한다.
|
||||||
|
String? logFile;
|
||||||
|
|
||||||
void _log(String msg) {
|
void _log(String msg) {
|
||||||
final t = DateTime.now();
|
final t = DateTime.now();
|
||||||
String two(int n) => n.toString().padLeft(2, '0');
|
String two(int n) => n.toString().padLeft(2, '0');
|
||||||
logs.add('[${two(t.hour)}:${two(t.minute)}:${two(t.second)}] $msg');
|
logs.add('[${two(t.hour)}:${two(t.minute)}:${two(t.second)}] $msg');
|
||||||
if (logs.length > 500) logs.removeAt(0);
|
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();
|
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 (_) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -208,9 +208,28 @@ class DownloadManager extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 상태가 바뀔 때: 화면 갱신 + 대기 중인 다음 작업 시작
|
/// 상태가 바뀔 때: 화면 갱신 + 대기 중인 다음 작업 시작
|
||||||
|
/// 작업 기록에 남기기 (앱이 연결해 준다): 다운로드 시작 · 완료 · 실패
|
||||||
|
void Function(String msg)? log;
|
||||||
|
final Map<String, DownloadState> _lastState = {};
|
||||||
|
|
||||||
void _changed() {
|
void _changed() {
|
||||||
for (final t in tasks) {
|
for (final t in tasks) {
|
||||||
if (t.state == DownloadState.done && _reported.add(t.id)) _finished.add(t);
|
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();
|
notifyListeners();
|
||||||
_pump();
|
_pump();
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import 'core/playlist.dart';
|
||||||
import 'platform/windows/com_guard.dart';
|
import 'platform/windows/com_guard.dart';
|
||||||
import 'platform/windows/desktop_shell.dart';
|
import 'platform/windows/desktop_shell.dart';
|
||||||
import 'platform/windows/exit_trace.dart';
|
import 'platform/windows/exit_trace.dart';
|
||||||
|
import 'platform/windows/session_marker.dart';
|
||||||
import 'platform/windows/single_instance.dart';
|
import 'platform/windows/single_instance.dart';
|
||||||
import 'platform/windows/window_memory.dart';
|
import 'platform/windows/window_memory.dart';
|
||||||
import 'services/platform_services.dart';
|
import 'services/platform_services.dart';
|
||||||
|
|
@ -58,7 +59,17 @@ Future<void> main(List<String> args) async {
|
||||||
}
|
}
|
||||||
|
|
||||||
final services = PlatformServices.create();
|
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();
|
await controller.init();
|
||||||
|
|
||||||
final bookmarks = BookmarksController();
|
final bookmarks = BookmarksController();
|
||||||
|
|
@ -120,6 +131,8 @@ Future<void> main(List<String> args) async {
|
||||||
ExitStep('동영상 목록 · 창 위치 저장', () async {
|
ExitStep('동영상 목록 · 창 위치 저장', () async {
|
||||||
controller.stopVideoListShare();
|
controller.stopVideoListShare();
|
||||||
await memory.save();
|
await memory.save();
|
||||||
|
controller.note('── 정상 종료 ──');
|
||||||
|
session.markClean();
|
||||||
}),
|
}),
|
||||||
ExitStep('창 종료', () async {}),
|
ExitStep('창 종료', () async {}),
|
||||||
];
|
];
|
||||||
|
|
@ -191,6 +204,7 @@ Future<void> main(List<String> args) async {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
downloads.addToEditList = controller.addDownloaded;
|
downloads.addToEditList = controller.addDownloaded;
|
||||||
|
downloads.log = controller.note;
|
||||||
unawaited(downloads.startClipboardWatch());
|
unawaited(downloads.startClipboardWatch());
|
||||||
|
|
||||||
// 탐색기 메뉴 · 두 번째 실행에서 온 요청 처리
|
// 탐색기 메뉴 · 두 번째 실행에서 온 요청 처리
|
||||||
|
|
@ -240,6 +254,14 @@ Future<void> main(List<String> args) async {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
final ctx = navigatorKey.currentContext;
|
final ctx = navigatorKey.currentContext;
|
||||||
if (ctx == null) return;
|
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 화면은 그 아래에 있음)
|
// 시작 화면을 "웹 브라우저" 로 정했으면 브라우저를 연다 (MKV 화면은 그 아래에 있음)
|
||||||
if (controller.settings.startScreen == 'browser') {
|
if (controller.settings.startScreen == 'browser') {
|
||||||
navigatorKey.currentState?.push(MaterialPageRoute<void>(
|
navigatorKey.currentState?.push(MaterialPageRoute<void>(
|
||||||
|
|
|
||||||
|
|
@ -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<String, dynamic>;
|
||||||
|
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 (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.13.4
|
sdk: ^3.13.4
|
||||||
|
|
|
||||||
|
|
@ -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<PlaylistEntry>)?> expandPlaylist(String url) async => null;
|
||||||
|
@override
|
||||||
|
Future<void> start(DownloadTask t, void Function() changed) async {
|
||||||
|
this.changed = changed;
|
||||||
|
t.state = DownloadState.downloading;
|
||||||
|
changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> pause(DownloadTask t) async {}
|
||||||
|
@override
|
||||||
|
Future<void> cancel(DownloadTask t) async {}
|
||||||
|
@override
|
||||||
|
Future<void> 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 = <String>[];
|
||||||
|
d.log = logged.add;
|
||||||
|
final t = d.addPage('https://vimeo.com/1')!..title = '영상';
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
t
|
||||||
|
..state = DownloadState.done
|
||||||
|
..totalBytes = 5 << 20;
|
||||||
|
b.changed!();
|
||||||
|
final t2 = d.addPage('https://vimeo.com/2')!..title = '두 번째';
|
||||||
|
await Future<void>.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();
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue