diff --git a/integration_test/support/shot_services.dart b/integration_test/support/shot_services.dart index 6edfb55..00f0656 100644 --- a/integration_test/support/shot_services.dart +++ b/integration_test/support/shot_services.dart @@ -29,6 +29,7 @@ PlatformServices shotServices() { findLanSmb: r.findLanSmb, diskSpace: (_) async => (107374182400, 536870912000), clock: r.clock, + vault: r.vault, updater: r.updater, usage: r.usage == null ? null : const FixedUsage(), models: r.models, diff --git a/integration_test/windows_vault_shot_test.dart b/integration_test/windows_vault_shot_test.dart index 91a4654..24b6be6 100644 --- a/integration_test/windows_vault_shot_test.dart +++ b/integration_test/windows_vault_shot_test.dart @@ -6,6 +6,7 @@ import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/vault_location.dart'; import 'package:jj_mkvmaker/core/vault_store.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; @@ -27,12 +28,22 @@ void main() { Directory(out).createSync(recursive: true); // 관리자 지시 (10/24): 폴더 이름을 실행마다 같게 (캡처의 "자리:" 줄이 흔들리지 않게 - 시험 쪽에서만) final tmp = testFixedTempDir('jj_vaultshot_fixed'); - final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + // 보관함 자리 (M3 VaultLocation 을 주입): 장면마다 [vaultHere] 를 바꾼다 + var vaultHere = tmp; + final c = AppController(PlatformServices( + mediaTool: ProcessMediaTool('x', 'y'), + storage: DesktopStorageService(), + vault: VaultLocation( + vaultDir: () async => vaultHere, + viewRoot: () async => Directory(p.join(tmp.path, 'view')), + pickFolder: () async => null, + ), + )); c.settings.vault.enabled = true; final key = GlobalKey(); Future shot(String name, Size size, Directory dir) async { - vaultDirOf = () async => dir; + vaultHere = dir; t.view.physicalSize = size; t.view.devicePixelRatio = 1; await t.runAsync(() => t.pumpWidget(RepaintBoundary( @@ -61,7 +72,7 @@ void main() { await v.mkdir('빈 폴더'); }); for (final (name, size) in [('open_412', const Size(412, 800)), ('open_960', const Size(960, 600))]) { - vaultDirOf = () async => dir; + vaultHere = dir; t.view.physicalSize = size; t.view.devicePixelRatio = 1; await t.runAsync(() => t.pumpWidget(RepaintBoundary( diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index a96d19b..5b4bc51 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -150,7 +150,7 @@ class AppController extends ChangeNotifier { } // 관리자 지시 (10/20): 강제로 끝나 남은 HWP 보기 작업 폴더 (바꾼 PDF 들). 앱 시작을 기다리게 하지 않는다 (28 지적: 파일 IO 를 // 기다리면 시험의 가짜 시간 안에서 끝나지 않았다 - 시작을 늦출 이유도 없음) - unawaited(cleanLeftovers(services.storage.tempDirectory, _log)); + unawaited(cleanLeftovers(services.storage.tempDirectory, services.vault.viewRoot, _log)); ffmpegVersion = await _tool.version(); if (ffmpegVersion == null) { _log(tr('⚠ FFmpeg 를 찾을 수 없습니다. MKV 만들기를 사용할 수 없습니다.')); diff --git a/lib/app/controller/app_runtime.dart b/lib/app/controller/app_runtime.dart index 96a1ca8..9e2ca42 100644 --- a/lib/app/controller/app_runtime.dart +++ b/lib/app/controller/app_runtime.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:path/path.dart' as p; import '../../core/ftp.dart' show FtpRegistry; @@ -7,7 +9,7 @@ import '../../core/remote_idle.dart'; import '../../core/sftp.dart' show SftpRegistry; import '../../core/smb.dart' show SmbRegistry; import '../../core/languages.dart' show preferredSubtitleSetting; -import '../../core/vault_view.dart' show cleanVaultExportJournal, removeStaleVaultViews, vaultViewRootOf; +import '../../core/vault_view.dart' show cleanVaultExportJournal, removeStaleVaultViews; import '../../core/webdav.dart'; import '../../l10n/tr.dart'; import '../../services/media_player.dart' show playerSubtitleScale; @@ -34,8 +36,8 @@ void applyRuntimeSettings(AppSettings settings) { outputRootOverride = (settings.mkv.outputRoot?.isNotEmpty ?? false) ? settings.mkv.outputRoot : null; } -/// [tempDir]: 앱의 임시 폴더 -Future cleanLeftovers(Future Function() tempDir, void Function(String msg) log) async { +/// [tempDir]: 앱의 임시 폴더 · [viewRoot]: 보관함 [보기] 자리 (VaultLocation.viewRoot) +Future cleanLeftovers(Future Function() tempDir, Future Function() viewRoot, void Function(String msg) log) async { // 관리자 지시 (10/20): 강제로 끝나 남은 HWP 보기 작업 폴더 (바꾼 PDF 들) try { final hwp = removeStaleHwpWork(await tempDir()); @@ -43,12 +45,12 @@ Future cleanLeftovers(Future Function() tempDir, void Function(Str } catch (_) {} // 관리자 지시 (10/21): 강제로 꺼져 남은 암호 보관함 보기 폴더 (잠깐 푼 평문) try { - final vault = removeStaleVaultViews((await vaultViewRootOf()).path); + final vault = removeStaleVaultViews((await viewRoot()).path); if (vault.isNotEmpty) log(trf('지난번에 남은 보관함 보기 임시 폴더를 지움: {0}개', [vault.length])); } catch (_) {} // 관리자 지시 (10/21): 꺼내다 강제로 꺼져 받는 폴더에 남은 평문 "이름.jjvtmp" (기록에 있는 것만) try { - final left = cleanVaultExportJournal((await vaultViewRootOf()).path); + final left = cleanVaultExportJournal((await viewRoot()).path); if (left.isNotEmpty) log(trf('지난번에 보관함에서 꺼내다 남은 임시 파일을 지움: {0}개', [left.length])); } catch (_) {} } diff --git a/lib/app/vault_location.dart b/lib/app/vault_location.dart new file mode 100644 index 0000000..3aee799 --- /dev/null +++ b/lib/app/vault_location.dart @@ -0,0 +1,51 @@ +/// 약속 (M3 VaultLocation): 암호 보관함이 쓰는 자리 셋 - 보관함 폴더 · [보기] 로 잠깐 푼 파일을 두는 곳 · [꺼내기] 받을 폴더 고르기. +/// 입력: 없음 (자리를 묻는 함수 셋). 출력: 폴더 (없으면 부르는 쪽이 만든다) · 고른 폴더 (취소면 null). 오류: 기기 자리를 못 얻으면 그 오류. +/// [VaultLocation.device] = 실제 자리 (앱 조립 PlatformServices.create 가 씀): +/// 보관함 - Android Download/JJ_MKVMaker/vault · Windows 문서\JJ_MKVMaker\vault, +/// 푼 파일 - 앱 전용 (Android getCacheDir · Windows 앱 데이터 폴더의 temp - 공유 폴더가 아님, 관리자 10/21), 고르기 - 파일 고르기 창. +/// [VaultLocation.under] = 앱 임시 폴더 아래 (PlatformServices 를 손으로 조립할 때의 처음 값 - 사용자 보관함 · 앱 데이터를 건드리지 않음). +/// 예전의 바꿀 수 있는 전역 함수 vaultDirOf · vaultViewRootOf · vaultPickFolder 를 대신한다 (설계 M3) +library; + +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +class VaultLocation { + /// 보관함 폴더 + final Future Function() vaultDir; + + /// [보기] 로 잠깐 푼 파일 · 꺼내기 기록을 두는 곳 + final Future Function() viewRoot; + + /// [꺼내기] 받을 폴더 고르기 (취소면 null) + final Future Function() pickFolder; + + const VaultLocation({required this.vaultDir, required this.viewRoot, required this.pickFolder}); + + /// 이 기기의 실제 자리 + static final device = VaultLocation( + vaultDir: () async { + if (Platform.isAndroid) { + final root = await const MethodChannel('jj_mkvmaker/android').invokeMethod('storageRoot') ?? '/storage/emulated/0'; + return Directory(p.join(root, 'Download', 'JJ_MKVMaker', 'vault')); + } + return Directory(p.join((await getApplicationDocumentsDirectory()).path, 'JJ_MKVMaker', 'vault')); + }, + viewRoot: () async { + if (Platform.isAndroid) return getTemporaryDirectory(); + return Directory(p.join((await getApplicationSupportDirectory()).path, 'temp')); + }, + pickFolder: () => FilePicker.getDirectoryPath(), + ); + + /// [tempDir] (앱 임시 폴더) 아래의 vault · vault_view. 고르기는 없음 (null) + factory VaultLocation.under(Future Function() tempDir) => VaultLocation( + vaultDir: () async => Directory(p.join(await tempDir(), 'vault')), + viewRoot: () async => Directory(p.join(await tempDir(), 'vault_view')), + pickFolder: () async => null, + ); +} diff --git a/lib/core/vault_view.dart b/lib/core/vault_view.dart index 3c6d2ce..9730f2e 100644 --- a/lib/core/vault_view.dart +++ b/lib/core/vault_view.dart @@ -1,14 +1,6 @@ import 'dart:io'; import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; - -/// 암호 보관함 [보기] 에서 잠깐 푼 파일을 두는 곳 (관리자 지시 10/21): 공유 폴더 (Download · 시스템 TEMP) 가 아니라 -/// 앱 전용 - Android getCacheDir (다른 앱이 못 봄), Windows 앱 데이터 폴더의 temp. 시험에서 바꾼다 -Future Function() vaultViewRootOf = () async { - if (Platform.isAndroid) return getTemporaryDirectory(); - return Directory(p.join((await getApplicationSupportDirectory()).path, 'temp')); -}; /// 푼 파일을 두는 폴더 이름 앞 (실행마다 새 폴더) const vaultViewPrefix = 'jj_vault_view_'; diff --git a/lib/services/platform_services.dart b/lib/services/platform_services.dart index a07fa19..a8c2c5e 100644 --- a/lib/services/platform_services.dart +++ b/lib/services/platform_services.dart @@ -6,6 +6,7 @@ import '../platform/common/media_kit_full_player.dart'; import '../platform/common/media_kit_player.dart'; import '../app/ai_upscale.dart' show AiUpscaler; import '../app/settings.dart'; +import '../app/vault_location.dart'; import '../core/download_detect.dart'; import '../core/lan_scan.dart' show LanHost; import '../platform/common/nllb_translator.dart'; @@ -78,6 +79,10 @@ class PlatformServices { /// 지금 시각 (없으면 실제 시계). 작업 기록의 [시:분:초] final DateTime Function()? clock; + /// 암호 보관함 자리 (설계 M3). 앱 조립 ([PlatformServices.create]) 은 실제 자리 ([VaultLocation.device]), + /// 손으로 조립하면 처음 값은 앱 임시 폴더 아래 ([VaultLocation.under] - 사용자 보관함 · 앱 데이터를 건드리지 않음) + final VaultLocation vault; + /// 이 기기 (Android · Windows · 메모리) - 기기마다 다르게 도는 곳은 이것을 본다 (M1, 처음 값 = 이 기기) final PlatformInfo platform; @@ -102,7 +107,9 @@ class PlatformServices { ModelStore? models, AppShell? shell, PlatformInfo? platform, + VaultLocation? vault, }) : models = models ?? ModelStore(), + vault = vault ?? VaultLocation.under(storage.tempDirectory), platform = platform ?? PlatformInfo.host, shell = shell ?? NoopShell(); @@ -112,6 +119,7 @@ class PlatformServices { final tool = ProcessMediaTool.locate(); final ffmpegDir = File(tool.ffmpeg).existsSync() ? p.dirname(tool.ffmpeg) : null; return PlatformServices( + vault: VaultLocation.device, mediaTool: tool, storage: DesktopStorageService(), createPlayer: MediaKitPreviewPlayer.new, @@ -150,6 +158,7 @@ class PlatformServices { MediaKitPreviewPlayer.ensureInitialized(); final storage = AndroidStorageService(); return PlatformServices( + vault: VaultLocation.device, mediaTool: FfmpegKitMediaTool(), storage: storage, updater: AndroidUpdater(storage), diff --git a/lib/ui/vault/vault_actions.dart b/lib/ui/vault/vault_actions.dart index af520cd..4c9bf6d 100644 --- a/lib/ui/vault/vault_actions.dart +++ b/lib/ui/vault/vault_actions.dart @@ -21,6 +21,7 @@ import '../reader_page.dart' show openReader; import '../theme.dart'; import 'vault_views.dart'; import 'vault_session.dart'; +import '../../app/vault_location.dart'; /// 약속 (보관함 나누기): 보관함 안 파일 하나에 하는 일 - 보기 · 꺼내기 · 지우기 · 이름 변경 · 깨진 항목 알림. /// - 입력: 화면 [context] · 작은 상태 [s] (열린 보관함 · 진행 막대 · 알림) · 항목 [e] @@ -116,8 +117,8 @@ Future vaultBrokenEntry(BuildContext context, VaultUiState s, VaultEntry e } /// 꺼내기: [pickFolder] 로 고른 곳에 (이름이 겹치면 " (2)"). 쓰는 동안의 평문 임시 파일은 꺼내기 기록에 남긴다 -Future vaultExportEntry(BuildContext context, VaultUiState s, VaultEntry e, Future Function() pickFolder) async { - final dir = await pickFolder(); +Future vaultExportEntry(BuildContext context, VaultUiState s, VaultEntry e, VaultLocation where) async { + final dir = await where.pickFolder(); if (dir == null || !context.mounted) return; var out = p.join(dir, e.name); for (var i = 2; await File(out).exists(); i++) { @@ -125,7 +126,7 @@ Future vaultExportEntry(BuildContext context, VaultUiState s, VaultEntry e } s.setBusy(true); try { - final j = VaultExportJournal((await vaultViewRootOf()).path); + final j = VaultExportJournal((await where.viewRoot()).path); await s.vault!.export(e, out, onTemp: j.add, onTempDone: j.remove); s.snack(trf('꺼냈습니다: {0}', [vDisplay(out)])); } catch (err) { @@ -253,8 +254,8 @@ Future vaultRemoveFolder(BuildContext context, VaultUiState s, String f) a } /// 폴더째 꺼내기 - [pickFolder] 로 고른 곳에 같은 구조로 (이름이 겹치면 " (2)"). 깨진 파일은 세어 알림 (나머지는 꺼냄) -Future vaultExportFolder(BuildContext context, VaultUiState s, String f, Future Function() pickFolder) async { - final dir = await pickFolder(); +Future vaultExportFolder(BuildContext context, VaultUiState s, String f, VaultLocation where) async { + final dir = await where.pickFolder(); if (dir == null || !context.mounted) return; final name = f.split('/').last; var root = p.join(dir, name); @@ -269,7 +270,7 @@ Future vaultExportFolder(BuildContext context, VaultUiState s, String f, F await Directory(local(sub)).create(recursive: true); } final files = s.vault!.filesUnder(f); - final journal = VaultExportJournal((await vaultViewRootOf()).path); + final journal = VaultExportJournal((await where.viewRoot()).path); for (var i = 0; i < files.length; i++) { final e = files[i]; if (s.vault == null) break; diff --git a/lib/ui/vault/vault_session.dart b/lib/ui/vault/vault_session.dart index dc27f86..03cc600 100644 --- a/lib/ui/vault/vault_session.dart +++ b/lib/ui/vault/vault_session.dart @@ -59,14 +59,18 @@ String vaultParentOf(String f) => f.contains('/') ? f.substring(0, f.lastIndexOf String vaultJoin(String parent, String name) => parent.isEmpty ? name : '$parent/$name'; /// 약속 (보관함 나누기): 보관함을 한 번 연 동안 잠깐 푼 파일을 두는 앱 전용 폴더 (jj_vault_view_…) -/// - [dir]: 처음 부를 때 [vaultViewRootOf] 아래에 새 폴더를 만들고, 그 뒤로는 같은 폴더를 돌려준다 +/// - [dir]: 처음 부를 때 [root] (VaultLocation.viewRoot) 아래에 새 폴더를 만들고, 그 뒤로는 같은 폴더를 돌려준다 /// - [clear]: 그 폴더를 지우고 잊는다 (다음 [dir] 은 새 폴더). 만든 적 없으면 아무것도 하지 않는다 /// - 오류: [dir] 은 폴더를 만들지 못하면 그 오류를 던진다. [clear] 는 던지지 않는다 (남으면 다음에 켤 때 지움) class VaultViewSession { + VaultViewSession(this.root); + + /// 푼 파일을 둘 앱 전용 폴더 (VaultLocation.viewRoot) + final Future Function() root; Directory? _session; Future dir() async { - final d = _session ??= Directory(p.join((await vaultViewRootOf()).path, '$vaultViewPrefix${DateTime.now().microsecondsSinceEpoch}')); + final d = _session ??= Directory(p.join((await root()).path, '$vaultViewPrefix${DateTime.now().microsecondsSinceEpoch}')); await d.create(recursive: true); return d; } @@ -215,7 +219,7 @@ Future<({bool move, bool permanent})?> askVaultAdd(BuildContext context, List bad})> putVaultAdd(VaultUiState s, List paths, VaultAddPlan plan, - {required Future Function() thumbTemp}) async { + {required Future Function() thumbTemp, required Future Function() viewRoot}) async { s.setBusy(true); final bad = {...plan.failed.keys}; var done = 0; @@ -241,7 +245,7 @@ Future<({int done, Set bad})> putVaultAdd(VaultUiState s, List p final st = await vStat(x.src); if (isDav(src)) { // 원격은 앱 전용 폴더 (jj_vault_view_… - 강제로 꺼져 남아도 다음에 켤 때 지움) 에 받고, 넣은 뒤 바로 지운다 - tmp ??= await Directory(p.join((await vaultViewRootOf()).path, '${vaultViewPrefix}in_${DateTime.now().microsecondsSinceEpoch}')) + tmp ??= await Directory(p.join((await viewRoot()).path, '${vaultViewPrefix}in_${DateTime.now().microsecondsSinceEpoch}')) .create(recursive: true); src = fetched = await vLocalCopy(x.src, tmp.path); } @@ -275,6 +279,7 @@ Future<({int done, Set bad})> putVaultAdd(VaultUiState s, List p /// 보관함이 잠겼거나 [paths] 가 비면 아무것도 하지 않는다 Future vaultAddFlow(BuildContext context, VaultUiState s, List paths, {required Future Function() thumbTemp, + required Future Function() viewRoot, Future Function(List paths, {required bool permanent})? deleteOriginals}) async { if (paths.isEmpty || s.vault == null) return; s.setBusyProgress(true, tr('넣을 것을 세는 중…')); @@ -283,7 +288,7 @@ Future vaultAddFlow(BuildContext context, VaultUiState s, List pat s.setBusyProgress(false, null); final pick = await askVaultAdd(context, paths, plan, cwd: s.cwd, canMove: deleteOriginals != null); if (pick == null || !context.mounted) return; - final r = await putVaultAdd(s, paths, plan, thumbTemp: thumbTemp); + final r = await putVaultAdd(s, paths, plan, thumbTemp: thumbTemp, viewRoot: viewRoot); final done = r.done, bad = r.bad; if (done > 0 || bad.isEmpty) { s.snack(bad.isEmpty diff --git a/lib/ui/vault_page.dart b/lib/ui/vault_page.dart index 79640bc..4cdab8e 100644 --- a/lib/ui/vault_page.dart +++ b/lib/ui/vault_page.dart @@ -2,11 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; import '../app/app_controller.dart'; import '../app/master_lock.dart'; @@ -26,17 +22,7 @@ Future openVault(BuildContext context, AppController c, {List add = const [], Future Function(List paths, {required bool permanent})? deleteOriginals}) => Navigator.of(context).push(MaterialPageRoute(builder: (_) => VaultPage(c: c, add: add, deleteOriginals: deleteOriginals))); -/// 보관함 폴더 (Android: Download/JJ_MKVMaker/vault · Windows: 문서\JJ_MKVMaker\vault). 시험에서 바꿈 -Future Function() vaultDirOf = () async { - if (Platform.isAndroid) { - final root = await const MethodChannel('jj_mkvmaker/android').invokeMethod('storageRoot') ?? '/storage/emulated/0'; - return Directory(p.join(root, 'Download', 'JJ_MKVMaker', 'vault')); - } - return Directory(p.join((await getApplicationDocumentsDirectory()).path, 'JJ_MKVMaker', 'vault')); -}; - -/// 꺼낼 폴더 고르기 (시험에서 바꿈) -Future Function() vaultPickFolder = () => FilePicker.getDirectoryPath(); +// 보관함 폴더 · 푼 파일 자리 · 꺼낼 폴더 고르기는 c.services.vault (app/vault_location.dart - 설계 M3, 예전 전역 함수 vaultDirOf · vaultPickFolder) class VaultPage extends StatefulWidget { final AppController c; @@ -77,7 +63,7 @@ class _VaultPageState extends State with WidgetsBindingObserver { ); /// 이번에 연 동안 잠깐 푼 파일을 두는 앱 전용 폴더 (jj_vault_view_…) - final _session = VaultViewSession(); + late final _session = VaultViewSession(widget.c.services.vault.viewRoot); AppController get c => widget.c; @@ -125,7 +111,7 @@ class _VaultPageState extends State with WidgetsBindingObserver { Future _check() async { try { - final d = await vaultDirOf(); + final d = await c.services.vault.vaultDir(); final has = await Vault.exists(d); if (mounted) { setState(() { @@ -248,11 +234,12 @@ class _VaultPageState extends State with WidgetsBindingObserver { final paths = _pendingAdd; if (paths.isEmpty || _s.vault == null) return; _pendingAdd = []; - await vaultAddFlow(context, _s, paths, thumbTemp: c.services.storage.tempDirectory, deleteOriginals: widget.deleteOriginals); + await vaultAddFlow(context, _s, paths, + thumbTemp: c.services.storage.tempDirectory, viewRoot: c.services.vault.viewRoot, deleteOriginals: widget.deleteOriginals); } void _onFolder(String f, String action) => switch (action) { - 'export' => vaultExportFolder(context, _s, f, vaultPickFolder), + 'export' => vaultExportFolder(context, _s, f, c.services.vault), 'rename' => vaultRenameFolder(context, _s, f), 'move' => vaultMoveFolder(context, _s, f), 'remove' => vaultRemoveFolder(context, _s, f), @@ -261,7 +248,7 @@ class _VaultPageState extends State with WidgetsBindingObserver { void _onEntry(VaultEntry e, String action) => switch (action) { 'view' => vaultViewEntry(context, c, _s, _session, e), - 'export' => vaultExportEntry(context, _s, e, vaultPickFolder), + 'export' => vaultExportEntry(context, _s, e, c.services.vault), 'rename' => vaultRenameEntry(context, _s, e), 'move' => vaultMoveEntry(context, _s, e), 'remove' => vaultRemoveEntry(context, _s, e), diff --git a/test/flutter_test_config.dart b/test/flutter_test_config.dart index b894289..956dc34 100644 --- a/test/flutter_test_config.dart +++ b/test/flutter_test_config.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; -import 'package:jj_mkvmaker/core/vault_view.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; /// 모든 단위 시험 파일 앞에 돈다 (flutter_test 의 flutter_test_config). @@ -11,8 +10,7 @@ import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; Future testExecutable(FutureOr Function() testMain) async { final root = Directory.systemTemp.createTempSync('jj_store_'); DesktopStorageService.tempRoot = root.path; - // 암호 보관함 보기 임시 폴더도 (기본은 실제 앱 데이터 폴더 - 시험이 건드리지 않게) - vaultViewRootOf = () async => Directory('${root.path}${Platform.pathSeparator}vault_view'); + // 암호 보관함 자리: 시험이 손으로 조립하는 PlatformServices 의 처음 값이 앱 임시 폴더 (위 tempRoot) 아래라 따로 바꾸지 않는다 (M3 VaultLocation) tearDownAll(() { try { root.deleteSync(recursive: true); diff --git a/test/vault_location_contract_test.dart b/test/vault_location_contract_test.dart new file mode 100644 index 0000000..80dbe12 --- /dev/null +++ b/test/vault_location_contract_test.dart @@ -0,0 +1,30 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/vault_location.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/services/platform_services.dart'; +import 'package:path/path.dart' as p; + +/// 약속 (M3 VaultLocation): 손으로 조립한 PlatformServices 의 보관함 자리는 앱 임시 폴더 아래 (사용자 보관함 · 앱 데이터를 건드리지 않음) · +/// 주입한 자리를 그대로 씀 · under 는 고르기 없음. 실제 자리 (device) 는 앱 조립 (create) 만 쓰고, 기기 확인은 보관함 화면 시험이 본다 +void main() { + test('손으로 조립하면 처음 값은 앱 임시 폴더 아래 vault · vault_view, 고르기는 없음', () async { + final s = PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()); + final temp = await s.storage.tempDirectory(); + expect((await s.vault.vaultDir()).path, p.join(temp, 'vault')); + expect((await s.vault.viewRoot()).path, p.join(temp, 'vault_view')); + expect(await s.vault.pickFolder(), isNull); + }); + + test('주입한 자리를 그대로 씀', () async { + final where = VaultLocation( + vaultDir: () async => Directory('X:/v'), + viewRoot: () async => Directory('X:/view'), + pickFolder: () async => 'X:/out', + ); + final s = PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), vault: where); + expect([(await s.vault.vaultDir()).path, (await s.vault.viewRoot()).path, await s.vault.pickFolder()], ['X:/v', 'X:/view', 'X:/out']); + }); +} diff --git a/test/vault_modules_test.dart b/test/vault_modules_test.dart index ba5420e..7d4ab54 100644 --- a/test/vault_modules_test.dart +++ b/test/vault_modules_test.dart @@ -14,15 +14,10 @@ import 'package:path/path.dart' as p; void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Directory tmp; - final wasView = vaultViewRootOf; - setUp(() { - tmp = Directory.systemTemp.createTempSync('jj_vaultmod_'); - vaultViewRootOf = () async => Directory(p.join(tmp.path, 'view')); - }); - tearDown(() async { - vaultViewRootOf = wasView; - await removeTempDirRetry(tmp); - }); + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_vaultmod_')); + tearDown(() async => removeTempDirRetry(tmp)); + // 보관함 [보기] 자리 (M3 VaultLocation.viewRoot 를 주입) + Future viewRoot() async => Directory(p.join(tmp.path, 'view')); test('vault_session (상태): 폴더 글 · 위 폴더 · 잇기 · 상태를 바꾸면 다시 그림', () { expect(vaultParentOf('가/나/다'), '가/나'); @@ -43,7 +38,7 @@ void main() { }); test('vault_session (보기 폴더): 한 번 연 동안 같은 폴더 · 지우면 잊고 다음은 새 폴더 · 없는 폴더 지우기는 조용히', () async { - final v = VaultViewSession(); + final v = VaultViewSession(viewRoot); await v.clear(); // 만든 적 없음 final a = await v.dir(), b = await v.dir(); expect(a.path, b.path); @@ -80,7 +75,7 @@ void main() { final said = []; final s = VaultUiState(changed: () {}, snack: said.add)..vault = vault; final thumbs = Directory(p.join(tmp.path, 'thumbs'))..createSync(); - final r = await putVaultAdd(s, [one.path, dir.path, missing], plan, thumbTemp: () async => thumbs.path); + final r = await putVaultAdd(s, [one.path, dir.path, missing], plan, thumbTemp: () async => thumbs.path, viewRoot: viewRoot); expect(r.done, 3); expect(r.bad, {missing}, reason: '넣지 못한 것만 (그 원본은 화면이 지우지 않음)'); expect(said, hasLength(1), reason: '처음 실패만 알림'); @@ -92,7 +87,7 @@ void main() { // 보관함이 잠겨 있으면 (vault = null) 하나도 넣지 않고 멈춘다 final p3 = VaultAddPlan()..files.add((src: one.path, folder: '', top: one.path)); final s3 = VaultUiState(changed: () {}, snack: said.add)..vault = null; - final r3 = await putVaultAdd(s3, [one.path], p3, thumbTemp: () async => thumbs.path); + final r3 = await putVaultAdd(s3, [one.path], p3, thumbTemp: () async => thumbs.path, viewRoot: viewRoot); expect(r3.done, 0); }); @@ -188,7 +183,7 @@ void main() { return const Scaffold(); }))); var finished = false; - unawaited(vaultAddFlow(ctx, s, paths, thumbTemp: () async => p.join(tmp.path, 'thumbs'), deleteOriginals: (ok, {required permanent}) async { + unawaited(vaultAddFlow(ctx, s, paths, thumbTemp: () async => p.join(tmp.path, 'thumbs'), viewRoot: viewRoot, deleteOriginals: (ok, {required permanent}) async { deleted.addAll(ok); for (final x in ok) { File(x).deleteSync(); // 실제로 지운다 (탐색기의 지우기 대신) @@ -216,4 +211,17 @@ void main() { expect(f.readAsBytesSync(), bytes[n], reason: '$n 바이트 그대로'); } }); + + // 설계 M3 (KdfParams): 반복 수는 화면 · 만들기에 넘기는 값 (처음 값 VaultCrypto.defaultIterations = 60만). 실제 반복 수로 한 번 - + // 실제 시험 폴더에 만들고 → 넣고 → 닫았다가 비밀번호로 다시 열어 → 같은 바이트 (다른 시험은 빨리 하려고 1000번) + test('실제 반복 수 (60만 번) 로 만들기 · 다시 열기 · 읽기 1회', () async { + final dir = Directory(p.join(tmp.path, 'real')); + final src = File(p.join(tmp.path, 'a.bin'))..writeAsBytesSync(List.generate(5000, (i) => i % 256)); + final v = await Vault.create(dir, 'pw-real'); + await v.add(src.path); + final again = await Vault.unlock(dir, 'pw-real'); + expect(again.entries.single.name, 'a.bin'); + expect(await again.readBytes(again.entries.single), src.readAsBytesSync()); + await expectLater(Vault.unlock(dir, 'wrong'), throwsA(anything), reason: '틀린 비밀번호는 열리지 않음'); + }, timeout: const Timeout(Duration(minutes: 3))); } diff --git a/test/vault_page_test.dart b/test/vault_page_test.dart index 530560f..a7d63eb 100644 --- a/test/vault_page_test.dart +++ b/test/vault_page_test.dart @@ -16,6 +16,7 @@ import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/reader_page.dart' show ReaderPage; import 'package:jj_mkvmaker/ui/vault_page.dart'; import 'package:path/path.dart' as p; +import 'package:jj_mkvmaker/app/vault_location.dart'; /// X-plore 비교 5-2 암호 보관함 화면 (관리자 결정 10/21): 만들기 창의 알림 · 넣기 (복사 / 원본 지우기) · 잠금 · 틀린 비밀번호 · /// 꺼내기 · 지우기 · [마스터로도 열기] (마스터를 초기화하면 그 길만 없어짐) @@ -24,17 +25,18 @@ const _fastIterations = 1000; void main() { late Directory tmp; - final was = vaultDirOf; - final wasPick = vaultPickFolder; + // 보관함 자리 (M3 VaultLocation 을 주입): 보관함 · [보기] 자리 · 꺼낼 폴더 모두 시험 폴더 + late VaultLocation where; setUp(() { tmp = Directory.systemTemp.createTempSync('jj_vaultui_'); - vaultDirOf = () async => Directory(p.join(tmp.path, 'vault')); - vaultPickFolder = () async => p.join(tmp.path, 'out'); + where = VaultLocation( + vaultDir: () async => Directory(p.join(tmp.path, 'vault')), + viewRoot: () async => Directory(p.join(tmp.path, 'viewroot')), + pickFolder: () async => p.join(tmp.path, 'out'), + ); Directory(p.join(tmp.path, 'out')).createSync(); }); tearDown(() async { - vaultDirOf = was; - vaultPickFolder = wasPick; MasterLock.instance = null; // Windows 는 막 쓴 파일을 백신 · 색인이 잠깐 잡고 있을 수 있다 (errno 32) - 몇 번 더 for (var i = 0; ; i++) { @@ -49,7 +51,7 @@ void main() { }); AppController ctl() { - final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), vault: where)); c.settings.explorer.style = 'xplore'; // E6: 이 시험은 X-plore 모양 (트리) 을 본다 - Windows 처음 값은 Total Commander c.settings.vault.enabled = true; return c; @@ -297,10 +299,7 @@ void main() { t.view.physicalSize = const Size(900, 1400); t.view.devicePixelRatio = 1; addTearDown(t.view.reset); - final viewRoot = Directory(p.join(tmp.path, 'viewroot')); - final wasRoot = vaultViewRootOf; - vaultViewRootOf = () async => viewRoot; - addTearDown(() => vaultViewRootOf = wasRoot); + final viewRoot = Directory(p.join(tmp.path, 'viewroot')); // where.viewRoot List leftovers() => viewRoot.existsSync() ? viewRoot.listSync(recursive: true).map((e) => e.path).toList() : const []; final photo = File(p.join(tmp.path, 'photo.png'))..writeAsBytesSync(img.encodePng(img.Image(width: 20, height: 10))); @@ -345,10 +344,7 @@ void main() { t.view.physicalSize = const Size(900, 1400); t.view.devicePixelRatio = 1; addTearDown(t.view.reset); - final viewRoot = Directory(p.join(tmp.path, 'viewroot')); - final wasRoot = vaultViewRootOf; - vaultViewRootOf = () async => viewRoot; - addTearDown(() => vaultViewRootOf = wasRoot); + final viewRoot = Directory(p.join(tmp.path, 'viewroot')); // where.viewRoot final remote = _Remote({'/r/remote.txt': 'from nas'}); final wasBackend = ShizukuRemote.backend, wasTemp = ShizukuRemote.tempDir; ShizukuRemote.backend = remote;