365 lines
13 KiB
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,
|
|
);
|
|
}
|
|
}
|