JJ_MKVMaker/lib/ui/vault_page.dart

365 lines
13 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import '../app/app_controller.dart';
import '../app/master_lock.dart';
import '../core/secret_gate.dart';
import '../core/vault_crypto.dart';
import '../core/vault_store.dart';
import '../l10n/tr.dart';
import 'vault/vault_actions.dart';
import 'vault/vault_session.dart';
import 'vault/vault_views.dart';
import 'jj_title_bar.dart';
/// X-plore 비교 5-2 암호 보관함 화면 (사용자 결정 10/21, 관리자 결정 - 설계안 위키 프로젝트/암호 보관함 · 루트 설계안 (10-21))
/// 약속 (보관함 나누기): 이 화면은 단계 (확인 · 만들기 · 잠김 · 여는 중 · 열림) 와 열고 잠그기만 맡고,
/// 넣기 · 파일 · 폴더 · 열쇠 일은 lib/ui/vault/ 의 모듈을 부른다 (작은 상태 [VaultUiState] 를 넘김)
Future<void> openVault(BuildContext context, AppController c,
{List<String> add = const [], Future<void> Function(List<String> paths, {required bool permanent})? deleteOriginals}) =>
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => VaultPage(c: c, add: add, deleteOriginals: deleteOriginals)));
// 보관함 폴더 · 푼 파일 자리 · 꺼낼 폴더 고르기는 c.services.vault (app/vault_location.dart - 설계 M3, 예전 전역 함수 vaultDirOf · vaultPickFolder)
class VaultPage extends StatefulWidget {
final AppController c;
/// 열면서 넣을 파일 (탐색기 [보관함에 넣기])
final List<String> add;
/// [원본 지우기 (이동)] 일 때 탐색기의 지우기 (확인 창 · [permanent] 가 아니면 휴지통)
final Future<void> Function(List<String> paths, {required bool permanent})? deleteOriginals;
/// 새 보관함 · 비밀번호 바꾸기의 PBKDF2 반복 수
final int iterations;
const VaultPage({super.key, required this.c, this.add = const [], this.deleteOriginals, this.iterations = VaultCrypto.defaultIterations});
@override
State<VaultPage> createState() => _VaultPageState();
}
enum _Stage { checking, create, locked, opening, open }
class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
_Stage _stage = _Stage.checking;
Directory? _dir;
String? _error;
int _fails = 0;
DateTime? _waitUntil, _hiddenAt;
final _pw = TextEditingController(), _pw2 = TextEditingController();
bool _understood = false;
late List<String> _pendingAdd = [...widget.add];
String _sort = 'added';
/// 모듈에 넘기는 작은 상태 (열린 보관함 · 진행 막대 · 지금 폴더 · 알림)
late final _s = VaultUiState(
changed: () {
if (mounted) setState(() {});
},
snack: _snack,
);
/// 이번에 연 동안 잠깐 푼 파일을 두는 앱 전용 폴더 (jj_vault_view_…)
late final _session = VaultViewSession(widget.c.services.vault.viewRoot);
AppController get c => widget.c;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
unawaited(_check());
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_lock(closeViews: false);
_pw.dispose();
_pw2.dispose();
super.dispose();
}
/// 앱이 뒤로 간 뒤 정한 시간이 지나 돌아오면 다시 잠금
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused || state == AppLifecycleState.hidden) {
_hiddenAt ??= DateTime.now();
} else if (state == AppLifecycleState.resumed) {
final at = _hiddenAt;
_hiddenAt = null;
if (at != null && _s.vault != null && DateTime.now().difference(at).inSeconds >= c.settings.vault.autoLockSeconds) {
setState(_lock);
}
}
}
/// 이 화면의 길 (잠글 때 위에 열린 보기 화면을 닫으려고)
ModalRoute<dynamic>? _route;
/// 잠금: 열쇠를 버리고, 위에 열린 보기 (그림 · 플레이어) 를 닫고, 잠깐 푼 파일을 지운다 (관리자 지시 10/21)
void _lock({bool closeViews = true}) {
_s.vault = null;
if (_stage == _Stage.open) _stage = _Stage.locked;
final r = _route;
if (closeViews && r != null && mounted && !r.isCurrent) Navigator.of(context).popUntil((x) => x == r);
unawaited(_session.clear());
}
Future<void> _check() async {
try {
final d = await c.services.vault.vaultDir();
final has = await Vault.exists(d);
if (mounted) {
setState(() {
_dir = d;
_stage = has ? _Stage.locked : _Stage.create;
});
}
} catch (e) {
if (mounted) setState(() => _error = trf('보관함 폴더를 열 수 없습니다: {0}', [e]));
}
}
void _snack(String t) => ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(t)));
Future<void> _create() async {
if (_pw.text.isEmpty || _pw.text != _pw2.text || !_understood) return;
setState(() {
_s.busy = true;
_stage = _Stage.opening;
});
try {
final v = await Vault.create(_dir!, _pw.text, iterations: widget.iterations);
_pw.clear();
_pw2.clear();
if (!mounted) return;
setState(() {
_s.vault = v;
_stage = _Stage.open;
});
await _addPending();
} catch (e) {
if (mounted) {
setState(() {
_stage = _Stage.create;
_error = trf('보관함을 만들지 못했습니다: {0}', [e]);
});
}
} finally {
if (mounted) setState(() => _s.busy = false);
}
}
Future<void> _unlock() async {
final wait = _waitUntil;
if (wait != null && DateTime.now().isBefore(wait)) return;
setState(() {
_stage = _Stage.opening;
_error = null;
});
try {
final v = await Vault.unlock(_dir!, _pw.text);
_pw.clear();
_fails = 0;
if (!mounted) return;
setState(() {
_s.vault = v;
_stage = _Stage.open;
});
await _addPending();
} on VaultAuthException {
_fails++;
if (_fails >= 5) {
_waitUntil = DateTime.now().add(const Duration(seconds: 30));
_fails = 0;
Timer(const Duration(seconds: 30), () => mounted ? setState(() => _waitUntil = null) : null);
}
if (mounted) {
setState(() {
_stage = _Stage.locked;
_error = _waitUntil != null ? tr('비밀번호가 여러 번 틀려 30초 뒤에 다시 넣을 수 있습니다') : tr('비밀번호가 틀렸습니다');
});
}
} on VaultFormatException {
if (mounted) {
setState(() {
_stage = _Stage.locked;
_error = tr('이 판이 모르는 보관함 형식입니다 - 앱을 새 판으로 올려 주세요 (보관함은 그대로 둡니다)');
});
}
} catch (e) {
if (mounted) {
setState(() {
_stage = _Stage.locked;
_error = trf('보관함을 열지 못했습니다: {0}', [e]);
});
}
}
}
/// [마스터로도 열기]: 마스터 비밀번호 확인 뒤 안전 저장소의 열쇠로
Future<void> _unlockWithMaster() async {
final lock = MasterLock.instance;
if (lock == null || !await SecretGate.pass(force: true)) return;
final key = (await lock.secrets.readAll())[MasterLock.vaultCopyKey];
if (key == null) {
_snack(tr('마스터로 여는 열쇠가 없습니다 (마스터를 초기화했으면 없어집니다) - 보관함 비밀번호로 여세요'));
return;
}
setState(() => _stage = _Stage.opening);
try {
final v = await Vault.unlockWithKey(_dir!, base64.decode(key));
if (!mounted) return;
setState(() {
_s.vault = v;
_stage = _Stage.open;
});
await _addPending();
} catch (e) {
if (mounted) {
setState(() {
_stage = _Stage.locked;
_error = trf('보관함을 열지 못했습니다: {0}', [e]);
});
}
}
}
/// 탐색기에서 고른 것 넣기 (한 번만 - 넣을 것은 비움)
Future<void> _addPending() async {
final paths = _pendingAdd;
if (paths.isEmpty || _s.vault == null) return;
_pendingAdd = [];
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, c.services.vault),
'rename' => vaultRenameFolder(context, _s, f),
'move' => vaultMoveFolder(context, _s, f),
'remove' => vaultRemoveFolder(context, _s, f),
_ => null,
};
void _onEntry(VaultEntry e, String action) => switch (action) {
'view' => vaultViewEntry(context, c, _s, _session, e),
'export' => vaultExportEntry(context, _s, e, c.services.vault),
'rename' => vaultRenameEntry(context, _s, e),
'move' => vaultMoveEntry(context, _s, e),
'remove' => vaultRemoveEntry(context, _s, e),
_ => null,
};
@override
Widget build(BuildContext context) {
_route ??= ModalRoute.of(context);
// 2단계: 보관함 안 폴더에서 ← 는 위 폴더로
return PopScope(
canPop: _stage != _Stage.open || _s.cwd.isEmpty,
onPopInvokedWithResult: (did, _) {
if (!did && _s.cwd.isNotEmpty) setState(() => _s.cwd = vaultParentOf(_s.cwd));
},
child: _scaffold(),
);
}
Widget _scaffold() => Scaffold(
appBar: jjTitleBar(
context,
title: tr('암호 보관함'),
actions: [
if (_stage == _Stage.open) ...[
IconButton(tooltip: tr('잠그기'), icon: const Icon(Icons.lock_outline), onPressed: () => setState(_lock)),
PopupMenuButton<String>(
tooltip: tr('더 보기'),
onSelected: (w) => switch (w) {
'mkdir' => vaultMakeFolder(context, _s),
'pw' => vaultChangePassword(context, _s, iterations: widget.iterations),
'master' => vaultToggleMasterCopy(context, c, _s, !c.settings.vault.masterCopy),
_ => null,
},
itemBuilder: (_) => [
PopupMenuItem(value: 'mkdir', child: Text(tr('새 폴더'))),
PopupMenuItem(value: 'pw', child: Text(tr('보관함 비밀번호 바꾸기'))),
CheckedPopupMenuItem(value: 'master', checked: c.settings.vault.masterCopy, child: Text(tr('마스터로도 열기'))),
],
),
],
],
),
body: Stack(children: [
switch (_stage) {
_Stage.checking => const Center(child: CircularProgressIndicator()),
_Stage.create => VaultCreateForm(
dirPath: _dir?.path ?? '',
pw: _pw,
pw2: _pw2,
understood: _understood,
busy: _s.busy,
error: _error,
onChanged: () => setState(() {}),
onUnderstood: (v) => setState(() => _understood = v),
onCreate: _create,
),
_Stage.locked => VaultUnlockForm(
dirPath: _dir?.path ?? '',
pw: _pw,
waiting: _waitUntil != null && DateTime.now().isBefore(_waitUntil!),
error: _error,
masterCopy: c.settings.vault.masterCopy,
onUnlock: _unlock,
onUnlockWithMaster: _unlockWithMaster,
),
_Stage.opening => Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
const CircularProgressIndicator(),
const SizedBox(height: 12),
Text(tr('보관함을 여는 중… (비밀번호 확인)')),
]),
),
_Stage.open => _list(),
},
if (_s.busy && _stage == _Stage.open)
Positioned(
left: 0,
right: 0,
top: 0,
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
const LinearProgressIndicator(),
if (_s.progress != null)
ColoredBox(
color: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(_s.progress!, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)),
),
),
]),
),
]),
);
Widget _list() {
final v = _s.vault!;
// 지운 · 옮긴 폴더 안에 있었으면 남은 가장 가까운 위 폴더로
while (_s.cwd.isNotEmpty && !v.folders.contains(_s.cwd)) {
_s.cwd = vaultParentOf(_s.cwd);
}
return VaultListView(
vault: v,
cwd: _s.cwd,
sort: _sort,
busy: _s.busy,
onCwd: (f) => setState(() => _s.cwd = f),
onSort: (x) => setState(() => _sort = x),
onFolder: _onFolder,
onEntry: _onEntry,
);
}
}