diff --git a/lib/app/ai_upscale.dart b/lib/app/ai_upscale.dart index 8884fc2..c570b82 100644 --- a/lib/app/ai_upscale.dart +++ b/lib/app/ai_upscale.dart @@ -191,6 +191,22 @@ class AiUpscaler { _cancelled = false; final tmp = await Directory.systemTemp.createTemp('jj_up_'); final out = p.join(tmp.path, 'up.png'); + // 결과를 돌려주지 못하면 (실패 · 취소) 임시 폴더를 지운다 - 돌려준 결과는 부른 쪽이 다 쓴 뒤 지운다 + var done = false; + try { + final r = await _upscaleInto(input, out, onProgress: onProgress, onEta: onEta); + done = true; + return r; + } finally { + if (!done) { + try { + await tmp.delete(recursive: true); + } catch (_) {} + } + } + } + + Future _upscaleInto(String input, String out, {void Function(double? p)? onProgress, void Function(Duration? left)? onEta}) async { try { // 176: 큰 그림 · 메모리가 적은 기기는 작은 타일 (메모리를 덜 쓴다) int? longSide; diff --git a/lib/core/dns_resolver.dart b/lib/core/dns_resolver.dart index 9f459a4..0ebbf42 100644 --- a/lib/core/dns_resolver.dart +++ b/lib/core/dns_resolver.dart @@ -34,10 +34,16 @@ class DnsConfig { /// 캐시 열쇠 (설정이 바뀌면 예전 답을 쓰지 않게) String get key => '$mode|$primary|$secondary|${mode == 'doh' ? dohUrl : ''}'; - /// 화면에 보일 이름: "8.8.8.8, 8.8.4.4" · DoH 주소 - String get label => mode == 'doh' ? dohUrl : [primary, secondary].where((s) => s.trim().isNotEmpty).join(', '); + /// 화면 · 작업 기록에 보일 이름: "8.8.8.8, 8.8.4.4" · DoH 주소는 호스트만 ("https://dns.nextdns.io/…", [hideUrlPaths]). + /// 설정 화면은 [dohUrl] 을 그대로 보인다 + String get label => mode == 'doh' ? hideUrlPaths(dohUrl) : [primary, secondary].where((s) => s.trim().isNotEmpty).join(', '); } +/// 글 안의 http(s) 주소에서 경로 · 질의를 가린다: "https://dns.nextdns.io/abc123" → "https://dns.nextdns.io/…". +/// NextDNS 처럼 DoH 주소의 경로에 개인 ID 가 있어 작업 기록 · app.log · 안내에 남기지 않는다 (관리자 결정 10/17) +String hideUrlPaths(String s) => + s.replaceAllMapped(RegExp(r'(https?://[^\s/?#)\]]+)[/?#][^\s)\]]*', caseSensitive: false), (m) => m[0]!.length == m[1]!.length + 1 ? m[0]! : '${m[1]}/…'); + /// DNS 서버 주소 (IPv4 · IPv6) 를 읽는다. 틀리면 null InternetAddress? parseDnsServer(String s) { var t = s.trim(); @@ -194,7 +200,7 @@ class DnsResolver { /// 서버들에 차례로 묻는다 (첫 서버가 답하지 않으면 다음 서버) Future<(DnsAnswer, String)> _ask(DnsConfig c, String name, int type) async { - if (c.mode == 'doh') return (await _doh(c, name, type), c.dohUrl); + if (c.mode == 'doh') return (await _doh(c, name, type), c.label); final servers = c.servers; if (servers.isEmpty) { throw DnsException(tr('DNS 서버 주소가 없습니다 (환경 설정 > 네트워크 · DNS)'), unreachable: true); @@ -301,14 +307,14 @@ class DnsResolver { body.add(chunk); } if (res.statusCode != 200) { - throw DnsException(trf('DoH 서버 ({0}) 가 HTTP {1} 로 답했습니다', [c.dohUrl, res.statusCode]), - unreachable: true, server: c.dohUrl); + throw DnsException(trf('DoH 서버 ({0}) 가 HTTP {1} 로 답했습니다', [c.label, res.statusCode]), + unreachable: true, server: c.label); } return parseDnsResponse(body.toBytes()); } on DnsException { rethrow; } catch (e) { - throw DnsException(trf('DoH 서버 ({0}) 에 닿지 않습니다: {1}', [c.dohUrl, e]), unreachable: true, server: c.dohUrl); + throw DnsException(hideUrlPaths(trf('DoH 서버 ({0}) 에 닿지 않습니다: {1}', [c.label, e])), unreachable: true, server: c.label); } finally { client.close(force: true); } diff --git a/lib/core/local_proxy.dart b/lib/core/local_proxy.dart index 3e917e8..af5bc11 100644 --- a/lib/core/local_proxy.dart +++ b/lib/core/local_proxy.dart @@ -235,6 +235,14 @@ class ProxyAuth { } } +/// 거절 기록에 남길 요청: "방식 주소:포트" 만 - 경로 · 질의 (? 뒤) 는 남기지 않는다 (관리자 결정 10/17, 개인정보) +String requestLineForLog(String method, String target) { + if (method == 'CONNECT') return '$method ${target.split('/').first}'; + final u = Uri.tryParse(target); + if (u == null || u.host.isEmpty) return '$method ?'; + return '$method ${u.host.contains(':') ? '[${u.host}]' : u.host}:${u.port}'; +} + /// 앱 안 HTTP 프록시 (127.0.0.1 의 빈 포트). CONNECT (https) 와 http 요청을 받아 [connector] 로 잇는다. /// - DNS 강제: [DnsConnector] - 웹 브라우저 · yt-dlp · aria2 · 앱의 HTTP 가 이리로 /// - Tor 화면: [TorConnector] - 웹 뷰가 이리로 (이름 풀기까지 Tor 안에서) @@ -251,7 +259,7 @@ class LocalProxy { /// 요구하는 인증 (null 이면 묻지 않음 - 시험용). 맞지 않으면 407 final ProxyAuth? auth; - /// 인증이 없거나 틀려 거절함 (요청 줄, 비밀값을 보냈는지) - 작업 기록 (비밀값 자체는 남기지 않는다) + /// 인증이 없거나 틀려 거절함 ("방식 주소:포트" ([requestLineForLog]), 비밀값을 보냈는지) - 작업 기록 (비밀값 자체는 남기지 않는다) void Function(String requestLine, bool hadCredentials)? onRejected; LocalProxy(this.connector, {this.onError, this.auth}); @@ -314,7 +322,7 @@ class LocalProxy { .map((l) => l.substring(l.indexOf(':') + 1)) .firstOrNull; if (!a.accepts(given)) { - onRejected?.call(lines.first, given != null); + onRejected?.call(requestLineForLog(method, target), given != null); client.add(latin1.encode('HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="JJ_MKVMaker"\r\n' 'Content-Length: 0\r\nConnection: close\r\n\r\n')); await client.flush(); diff --git a/lib/ui/ai_upscale_ui.dart b/lib/ui/ai_upscale_ui.dart index a5c28db..ed94d9b 100644 --- a/lib/ui/ai_upscale_ui.dart +++ b/lib/ui/ai_upscale_ui.dart @@ -528,10 +528,17 @@ Future qualityUp(BuildContext context, AppController c, {required Strin progress.value = x; prog(x); }); - if (cancelled) return; - // 크기는 원본 기준 (설정의 배율, 1배면 원본 크기 그대로) · 이름_q - final o = upscaleOptionsOf(c, outDir: outDir); - saved = await saveUpscaled(four, source: nameFrom, original: input, o: o, suffix: '_q'); + try { + if (cancelled) return; + // 크기는 원본 기준 (설정의 배율, 1배면 원본 크기 그대로) · 이름_q + final o = upscaleOptionsOf(c, outDir: outDir); + saved = await saveUpscaled(four, source: nameFrom, original: input, o: o, suffix: '_q'); + } finally { + // 4배 결과의 임시 폴더 (jj_up_) - 저장했든 못 했든 지운다 + try { + await Directory(p.dirname(four)).delete(recursive: true); + } catch (_) {} + } }, onCancel: () { cancelled = true; engine.cancel(); diff --git a/test/ai_upscale_test.dart b/test/ai_upscale_test.dart index 451d5cd..74a0ca5 100644 --- a/test/ai_upscale_test.dart +++ b/test/ai_upscale_test.dart @@ -163,6 +163,8 @@ void main() { final up = fake((i, o) => File(o).writeAsBytesSync(img.encodePng(img.Image(width: 32, height: 24)))); final out = await up.upscale4x(input); expect(File(out).existsSync(), true); + // 돌려받은 결과의 임시 폴더는 부른 쪽이 지운다 + Directory(p.dirname(out)).deleteSync(recursive: true); }); test('엔진 기록의 실패 줄을 알아챈다 (0 으로 끝나도)', () { diff --git a/test/batch_test.dart b/test/batch_test.dart index c339c9e..102da61 100644 --- a/test/batch_test.dart +++ b/test/batch_test.dart @@ -28,6 +28,8 @@ import 'package:jj_mkvmaker/ui/home_page.dart'; import 'package:jj_mkvmaker/ui/settings_page.dart'; import 'package:path/path.dart' as p; +import 'support/temp_dirs.dart'; + class _Recognizer implements SpeechRecognizer { @override void cancel() {} @@ -115,6 +117,7 @@ AppController _plain() => AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); void main() { + deleteTempDirsAtEnd(); test('여러 개 선택: 체크 · 전체 선택 · 일괄 대상 · 선택 제거', () async { final dir = Directory.systemTemp.createTempSync('jj_batch_'); addTearDown(() => dir.deleteSync(recursive: true)); @@ -668,7 +671,7 @@ void main() { tester.view.physicalSize = const Size(1500, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); - final dir = Directory.systemTemp.createTempSync('jj_nav_'); + final dir = tempDirUntilEnd('jj_nav_'); final c = _plain(); final d = DownloadManager(backends: [_Backend()], settings: () => c.settings, readClipboard: () async => null); final bm = BookmarksController(p.join(dir.path, 'bm.json')); diff --git a/test/bookmark_folder_test.dart b/test/bookmark_folder_test.dart index 4783a9d..de08f3a 100644 --- a/test/bookmark_folder_test.dart +++ b/test/bookmark_folder_test.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'package:flutter/gestures.dart' show kSecondaryButton; import 'package:flutter/material.dart'; @@ -8,13 +7,16 @@ import 'package:jj_mkvmaker/core/bookmarks.dart'; import 'package:jj_mkvmaker/ui/bookmark_ui.dart'; import 'package:path/path.dart' as p; +import 'support/temp_dirs.dart'; + BookmarksController _bm() { - // 저장은 기다리지 않고 뒤에서 하므로, 시험이 끝나도 폴더를 바로 지우지 않는다 (임시 폴더) - final dir = Directory.systemTemp.createTempSync('jj_bmf_'); + // 저장은 기다리지 않고 뒤에서 하므로, 시험이 끝나도 폴더를 바로 지우지 않는다 - 파일 끝에 지운다 + final dir = tempDirUntilEnd('jj_bmf_'); return BookmarksController(p.join(dir.path, 'bookmarks.json')); } void main() { + deleteTempDirsAtEnd(); test('폴더 안에 폴더 · 즐겨찾기, 경로 · 검색 · 개수, 폴더로 옮기기 규칙', () { final bm = _bm(); final music = bm.addFolder('음악'); diff --git a/test/browser_test.dart b/test/browser_test.dart index 9a52fe7..760d6ae 100644 --- a/test/browser_test.dart +++ b/test/browser_test.dart @@ -20,6 +20,8 @@ import 'package:jj_mkvmaker/ui/browser_page.dart'; import 'package:jj_mkvmaker/ui/downloads_page.dart'; import 'package:path/path.dart' as p; +import 'support/temp_dirs.dart'; + class _Backend implements DownloadBackend { @override final DownloadKind kind; @@ -99,6 +101,7 @@ class _ProbeState extends State<_Probe> { } void main() { + deleteTempDirsAtEnd(); group('즐겨찾기 트리', () { test('추가 · 찾기 · 이동 · 삭제', () { final t = BookmarkTree.defaults(); @@ -479,7 +482,7 @@ void main() { tester.view.physicalSize = const Size(1500, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); - final dir = Directory.systemTemp.createTempSync('jj_bm_'); + final dir = tempDirUntilEnd('jj_bm_'); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final bm = BookmarksController(p.join(dir.path, 'bookmarks.json')); diff --git a/test/dns_proxy_test.dart b/test/dns_proxy_test.dart index 3fc9bcd..008d432 100644 --- a/test/dns_proxy_test.dart +++ b/test/dns_proxy_test.dart @@ -105,6 +105,21 @@ void main() { }); }); + test('작업 기록: DoH 주소는 호스트만 · 거절 기록은 방식 주소:포트만 (관리자 결정 10/17, 개인정보)', () { + const doh = DnsConfig(mode: 'doh', dohUrl: 'https://dns.nextdns.io/abc123'); + expect(doh.label, 'https://dns.nextdns.io/…'); + expect(doh.dohUrl, 'https://dns.nextdns.io/abc123', reason: '설정 값은 그대로'); + expect(const DnsConfig(mode: 'doh', dohUrl: 'https://dns.google').label, 'https://dns.google'); + expect(const DnsConfig(mode: 'custom', primary: '8.8.8.8', secondary: '8.8.4.4').label, '8.8.8.8, 8.8.4.4'); + expect(hideUrlPaths('DoH 서버 (x) 에 닿지 않습니다: HttpException: closed, uri = https://dns.nextdns.io:443/abc123?dns=AAA'), + 'DoH 서버 (x) 에 닿지 않습니다: HttpException: closed, uri = https://dns.nextdns.io:443/…'); + expect(hideUrlPaths('(https://a.example/p/q) · http://b.example/'), '(https://a.example/…) · http://b.example/'); + expect(requestLineForLog('CONNECT', 'www.example.com:443'), 'CONNECT www.example.com:443'); + expect(requestLineForLog('GET', 'http://www.example.com/secret/path?token=abc'), 'GET www.example.com:80'); + expect(requestLineForLog('POST', 'https://api.example.com:8443/x?y=1'), 'POST api.example.com:8443'); + expect(requestLineForLog('GET', '/local?q=1'), 'GET ?'); + }); + test('DNS 질문 · 답 읽기 (줄임 표시)', () { final q = buildDnsQuery('a.example.com', 1, id: 0x1234); expect(q.sublist(0, 2), [0x12, 0x34]); diff --git a/test/support/temp_dirs.dart b/test/support/temp_dirs.dart new file mode 100644 index 0000000..2982681 --- /dev/null +++ b/test/support/temp_dirs.dart @@ -0,0 +1,32 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// 뒤에서 저장이 끝나는 것 (책갈피 저장 등) 을 기다렸다가 지울 임시 폴더 - 시험 파일이 끝날 때 지운다. +/// 시험마다 바로 지우면 늦게 끝난 저장이 폴더를 다시 만들어 %TEMP% 에 jj_* 가 남았다 (관리자 지시 10/17, tool/test_temp_check.ps1). +final _later = []; + +/// [prefix] 로 임시 폴더를 만들고, 파일 끝 ([deleteTempDirsAtEnd]) 에 지우도록 적어 둔다 +Directory tempDirUntilEnd(String prefix) { + final d = Directory.systemTemp.createTempSync(prefix); + _later.add(d); + return d; +} + +/// main() 에서 한 번 부른다: 파일의 시험이 다 끝나면 [tempDirUntilEnd] 로 만든 폴더를 지운다 +/// (늦게 끝난 저장이 다시 만들 수 있어 잠깐씩 기다리며 몇 번 본다) +void deleteTempDirsAtEnd() { + tearDownAll(() async { + for (var round = 0; round < 5; round++) { + await Future.delayed(const Duration(milliseconds: 200)); + for (final d in _later) { + try { + if (d.existsSync()) d.deleteSync(recursive: true); + } on FileSystemException { + // 잠깐 잡혀 있으면 다음 차례에 + } + } + } + _later.removeWhere((d) => !d.existsSync()); + }); +} diff --git a/tool/test_temp_check.ps1 b/tool/test_temp_check.ps1 new file mode 100644 index 0000000..adaa7f7 --- /dev/null +++ b/tool/test_temp_check.ps1 @@ -0,0 +1,44 @@ +# 전체 시험 + 임시 폴더 흔적 검사 (관리자 지시 10/17): 시험이 %TEMP%\jj_* 폴더를 남기지 않는지. +# - 시험 전 · 뒤의 %TEMP% 를 비교해, 새로 남은 폴더 중 이름 앞부분이 시험 소스 (test\*.dart) 의 createTemp('jj…') 와 같은 것만 본다 +# (앱 · 다른 프로그램이 만든 jj_mkvmaker 등은 세지 않음). 하나라도 남으면 이름을 보이고 실패 (exit 2). +# - 시험 파일들은 동시에 돌아 파일마다 스스로 확인할 수 없어 (누가 만든 폴더인지 모름) 전체 실행 앞뒤로 본다. +# - 다른 worktree · 세션이 같은 시험을 동시에 돌리면 그쪽 폴더가 섞일 수 있다 (이름으로 확인). +# - 이미 있던 폴더는 건드리지 않는다 (지우지 않음). +# 사용 (app 폴더에서): powershell -ExecutionPolicy Bypass -File tool\test_temp_check.ps1 [flutter test 인자…] +$ErrorActionPreference = 'Stop' +$app = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$temp = [System.IO.Path]::GetTempPath() + +# 시험 소스가 쓰는 임시 폴더 이름 앞부분 (createTemp('jj_x_') · createTempSync('jj_x_')) +$prefixes = @(Get-ChildItem (Join-Path $app 'test') -Recurse -Filter *.dart | + ForEach-Object { [regex]::Matches((Get-Content $_.FullName -Raw -Encoding utf8), 'createTemp(?:Sync)?\(''(jj[^''$]*)''') } | + ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique) +if ($prefixes.Count -eq 0) { throw '시험 소스에서 임시 폴더 이름을 찾지 못했습니다' } + +function Get-TestTemp { + @(Get-ChildItem $temp -Directory -Filter 'jj*' -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) +} + +$before = @{} +foreach ($n in Get-TestTemp) { $before[$n] = $true } +Push-Location $app +try { + flutter test @args + $code = $LASTEXITCODE +} finally { + Pop-Location +} +# 시험이 끝난 뒤 지우기가 늦게 끝나는 것 (백신 · 색인) 을 잠깐 기다린다 +Start-Sleep -Seconds 3 +$left = @(Get-TestTemp | Where-Object { -not $before.ContainsKey($_) } | Where-Object { + $n = $_ + @($prefixes | Where-Object { $n.StartsWith($_) }).Count -gt 0 + }) +if ($left.Count -gt 0) { + Write-Host ("시험이 남긴 임시 폴더 {0}개 (%TEMP%):" -f $left.Count) + $left | Sort-Object | ForEach-Object { Write-Host " $_" } + if ($code -eq 0) { exit 2 } +} else { + Write-Host '임시 폴더 흔적: 새로 남은 것 0' +} +exit $code