JJ_MKVMaker/lib/ui/master_prompt.dart

392 lines
18 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import '../app/app_controller.dart';
import '../app/master_lock.dart';
import '../app/settings.dart';
import '../core/secret_gate.dart';
import '../services/secret_store.dart';
import '../l10n/tr.dart';
import 'confirm.dart';
import 'theme.dart';
/// 124: 마스터 비밀번호 입력 창.
/// - 마스터를 넣으면 풀린다 (앱이 켜져 있는 동안).
/// - 최상 비밀번호를 넣으면 초기화 창 ([forChange] 면 최상도 바꾸기 · 끄기 허락으로 받는다).
/// - [비밀번호를 잊었습니다]: 저장된 비밀번호를 지우고 마스터를 초기화 (비밀번호가 새어 나가지 않게).
/// 써도 되면 true.
Future<bool> askMaster(BuildContext context, AppController c, MasterLock lock,
{String? reason, bool forChange = false}) async {
final r = await showDialog<_Result>(
context: context,
barrierDismissible: false,
builder: (_) => _MasterDialog(lock: lock, reason: reason, forChange: forChange),
);
if (!context.mounted) return false;
switch (r) {
case _Result.ok:
return true;
case _Result.superPassword:
if (forChange) return true;
await showSuperReset(context, c, lock);
return lock.unlocked || !lock.hasMaster;
case _Result.forgot:
return forgotMaster(context, c, lock);
case _Result.cancel:
case null:
return false;
}
}
enum _Result { ok, superPassword, forgot, cancel }
class _MasterDialog extends StatefulWidget {
const _MasterDialog({required this.lock, this.reason, required this.forChange});
final MasterLock lock;
final String? reason;
final bool forChange;
@override
State<_MasterDialog> createState() => _MasterDialogState();
}
class _MasterDialogState extends State<_MasterDialog> {
final _text = TextEditingController();
bool _busy = false;
String? _error;
Timer? _tick;
MasterLock get lock => widget.lock;
@override
void initState() {
super.initState();
_tick = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted && lock.waitUntil != null) setState(() {});
});
}
@override
void dispose() {
_tick?.cancel();
_text.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_busy || _text.text.isEmpty || lock.waiting != null) return;
setState(() => _busy = true);
final r = await lock.check(_text.text);
if (!mounted) return;
setState(() => _busy = false);
switch (r) {
case MasterInput.master:
Navigator.pop(context, _Result.ok);
case MasterInput.superPassword:
Navigator.pop(context, _Result.superPassword);
case MasterInput.wrong:
case MasterInput.wait:
_text.clear();
setState(() => _error = trf('비밀번호가 틀렸습니다 ({0}번)', [lock.fails]));
}
}
@override
Widget build(BuildContext context) {
final wait = lock.waiting;
return AlertDialog(
scrollable: true,
icon: const Icon(Icons.lock_outline),
title: Text(widget.forChange ? tr('지금 마스터 (또는 최상) 비밀번호') : tr('마스터 비밀번호')),
content: SizedBox(
width: 420,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(widget.reason ?? tr('저장된 비밀번호를 쓰는 기능 · 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.')),
const SizedBox(height: 12),
TextField(
controller: _text,
obscureText: true,
autofocus: true,
enabled: !_busy && wait == null,
decoration: InputDecoration(labelText: tr('비밀번호'), errorText: _error),
onSubmitted: (_) => _submit(),
),
if (wait != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(trf('여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다', [wait.inSeconds + 1]),
style: TextStyle(color: JjColors.alert)),
),
if (_busy) const Padding(padding: EdgeInsets.only(top: 8), child: LinearProgressIndicator()),
if (!widget.forChange)
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: _busy ? null : () => Navigator.pop(context, _Result.forgot),
child: Text(tr('비밀번호를 잊었습니다')),
),
),
]),
),
actions: [
TextButton(onPressed: _busy ? null : () => Navigator.pop(context, _Result.cancel), child: Text(tr('취소'))),
FilledButton(onPressed: _busy || wait != null ? null : _submit, child: Text(tr('확인'))),
],
);
}
}
/// 저장된 실제 비밀번호 · API 키를 지운다 (서버 목록 · 아이디는 남김)
Future<void> clearStoredSecrets(AppController c) => c.updateSettings((s) => s
..webdavServers = [for (final x in s.webdavServers) x.copyWith(password: '')]
// 88: SFTP 의 비밀번호 · 개인 키 · 키 암호도 (믿은 호스트 키는 비밀이 아니라 남김)
..sftpServers = [for (final x in s.sftpServers) x.copyWith(password: '', privateKey: '', keyPassphrase: '')]
..smbServers = [for (final x in s.smbServers) x.copyWith(password: '')]
..ftpServers = [for (final x in s.ftpServers) x.copyWith(password: '')]
// AI 서비스 API 키도 (문구 "비밀번호 · API 키" 와 같게 - 예전에는 남았다, 관리자 결정 10/17)
..aiImage.services = [for (final x in s.aiImage.services) x.copyWith(apiKey: '')]
..openSubtitlesKey = ''
..openSubtitlesPassword = '').then((_) async {
// 관리자 판단 (10/21): 다른 판 (새 판) 이 안전 저장소에 넣은 이 판이 모르는 비밀번호 · 키도 (마스터 · 최상은 남김)
await c.settingsStore?.clearForeignSecrets();
});
/// 앱 설정 전체를 처음 상태로 (다음에 켤 때부터 모두 적용되도록 앱을 다시 켜 달라고 한다)
/// 관리자 판단 (10/21): 이 판이 모르는 설정 키 (다른 판이 쓴 것) 도 지운다 - 사용자가 "전체" 를 골랐고, 새 판으로 돌아갔을 때
/// 예전 값이 되살아나면 더 헷갈린다. 모르는 키를 남기는 규칙 (40) 은 보통 저장 때만 (새 AppSettings 는 [AppSettings.extra] 가 비어 있다).
/// 안전 저장소의 이 판이 모르는 열쇠는 [저장된 실제 비밀번호 · API 키] 를 골랐을 때만 지운다 ([clearStoredSecrets])
Future<void> resetAllSettings(AppController c) async {
c.settings = AppSettings()..extra = {};
await c.updateSettings((_) {});
}
/// 마스터를 잊었을 때: 저장된 비밀번호를 지우고 마스터를 초기화한다 (확인 뒤). 했으면 true
Future<bool> forgotMaster(BuildContext context, AppController c, MasterLock lock) async {
final ok = await confirmAction(
context,
title: tr('저장된 비밀번호를 지우고 마스터 초기화'),
body: tr('마스터 비밀번호를 모르면 저장된 비밀번호를 쓸 수 없습니다. 비밀번호가 새어 나가지 않도록, '
'저장된 WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키와 API 키를 지우고 마스터 비밀번호를 초기화합니다. '
'서버 목록 · 아이디 · 다른 설정은 그대로이며, 비밀번호는 다시 넣어야 합니다.'),
ok: tr('지우고 초기화'),
);
if (!ok || !context.mounted) return false;
await clearStoredSecrets(c);
await lock.clearMaster();
if (context.mounted) {
await _done(context, c, lock, [tr('저장된 비밀번호 · API 키를 지웠습니다'), tr('마스터 비밀번호를 초기화했습니다')]);
}
return true;
}
/// 최상 비밀번호를 넣었을 때: 무엇을 초기화할지 고른다 (처음 체크는 마스터만) → 한 번 더 확인 → 하고 알림
Future<void> showSuperReset(BuildContext context, AppController c, MasterLock lock) async {
var master = lock.hasMaster, secrets = false, settings = false;
final go = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
scrollable: true,
icon: const Icon(Icons.restart_alt),
title: Text(tr('최상 비밀번호 - 초기화')),
content: SizedBox(
width: 460,
child: Column(mainAxisSize: MainAxisSize.min, children: [
Text(tr('초기화할 것을 고르세요.')),
CheckboxListTile(
value: master,
onChanged: lock.hasMaster ? (v) => set(() => master = v!) : null,
title: Text(tr('마스터 비밀번호')),
),
CheckboxListTile(
value: secrets,
onChanged: (v) => set(() => secrets = v!),
title: Text(tr('저장된 실제 비밀번호 · API 키')),
// 145: 10/18 에 생긴 SFTP · SMB · FTP 비밀번호 · 키도 지운다 (clearStoredSecrets)
subtitle: Text('${tr('WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다.')}\n'
'${tr('다른 판에서 넣은 비밀번호 · 키도 지웁니다.')}'),
),
CheckboxListTile(
value: settings,
onChanged: (v) => set(() => settings = v!),
title: Text(tr('앱 설정 전체 (처음 상태로)')),
// 145: 파일 탐색기 즐겨찾기 (73) 는 설정 안에 있어 함께 지워진다 - 예전 글은 "즐겨찾기는 그대로"
subtitle: Text('${tr('서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다.')}\n'
// 관리자 판단 (10/21): 이 판이 모르는 설정 키도 지운다 (resetAllSettings)
'${tr('다른 판에서 만든 설정도 지웁니다 (다른 판이 넣은 비밀번호는 [저장된 실제 비밀번호 · API 키] 를 함께 골라야 지워집니다).')}'),
),
// X-plore 비교 5-2 (관리자 결정 10/21): 보관함은 초기화 대상이 아님 (풀 수 있는 열쇠가 없음)
if (c.settings.vault.enabled)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(tr('암호 보관함은 초기화되지 않습니다 - 보관함 파일은 그대로 남고 보관함 비밀번호로만 열 수 있습니다. [마스터로도 열기] 를 켰다면 마스터를 초기화할 때 그 길만 없어집니다'),
style: const TextStyle(fontSize: 12)),
),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(
onPressed: master || secrets || settings ? () => Navigator.pop(ctx, true) : null,
child: Text(tr('초기화')),
),
],
),
),
);
if (go != true || !context.mounted) return;
final what = [
if (master) tr('마스터 비밀번호'),
if (secrets) tr('저장된 실제 비밀번호 · API 키'),
if (settings) tr('앱 설정 전체 (처음 상태로)'),
];
final sure = await confirmAction(
context,
title: tr('정말 초기화할까요?'),
body: '${what.map((x) => '• $x').join('\n')}\n\n${tr('되돌릴 수 없습니다.')}',
ok: tr('초기화'),
);
if (!sure || !context.mounted) return;
final done = <String>[];
if (secrets) {
await clearStoredSecrets(c);
done.add(tr('저장된 비밀번호 · API 키를 지웠습니다'));
}
if (settings) {
await resetAllSettings(c);
done.add(tr('앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)'));
}
if (master) {
await lock.clearMaster();
done.add(tr('마스터 비밀번호를 초기화했습니다'));
} else {
lock.unlocked = true; // 최상을 아는 사람
}
if (context.mounted) await _done(context, c, lock, done);
}
/// 무엇을 했는지 알리고, 마스터가 없어졌으면 새로 정할 수 있게
Future<void> _done(BuildContext context, AppController c, MasterLock lock, List<String> done) async {
final setNew = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
icon: Icon(Icons.check_circle_outline, color: JjColors.accent),
title: Text(tr('초기화했습니다')),
content: Text(done.map((x) => '• $x').join('\n')),
actions: [
if (!lock.hasMaster)
TextButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('새 마스터 비밀번호 정하기'))),
FilledButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('닫기'))),
],
),
);
if (setNew == true && context.mounted) await setPasswordDialog(context, c, lock, superPassword: false);
}
/// 새 마스터 · 최상 비밀번호를 정한다 (두 번 넣기). 정했으면 true
Future<bool> setPasswordDialog(BuildContext context, AppController c, MasterLock lock, {required bool superPassword}) async {
final a = TextEditingController(), b = TextEditingController();
String? error;
var busy = false;
final ok = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) {
Future<void> save() async {
if (a.text.isEmpty) return set(() => error = tr('비밀번호를 넣으세요'));
if (a.text != b.text) return set(() => error = tr('두 번 넣은 비밀번호가 다릅니다'));
set(() => busy = true);
final fine = superPassword ? await lock.setSuper(a.text) : await lock.setMaster(a.text);
if (!ctx.mounted) return;
if (!fine) {
return set(() {
busy = false;
error = superPassword
? tr('마스터 비밀번호와 같게 정할 수 없습니다')
: tr('최상 비밀번호와 같게 정할 수 없습니다');
});
}
Navigator.pop(ctx, true);
}
return AlertDialog(
scrollable: true,
title: Text(superPassword ? tr('최상 비밀번호 정하기') : tr('마스터 비밀번호 정하기')),
content: SizedBox(
width: 420,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(superPassword
? tr('최상 비밀번호는 모든 것을 초기화하는 열쇠입니다. 마스터 입력 창에 넣으면 초기화 창이 열립니다.')
: tr('저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다. 잊으면 최상 비밀번호로 초기화하거나, '
'저장된 비밀번호를 지우고 초기화할 수 있습니다.')),
const SizedBox(height: 8),
Text(tr('이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.'),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
TextField(
controller: a,
obscureText: true,
autofocus: true,
enabled: !busy,
decoration: InputDecoration(labelText: tr('새 비밀번호'))),
TextField(
controller: b,
obscureText: true,
enabled: !busy,
decoration: InputDecoration(labelText: tr('한 번 더'), errorText: error),
onSubmitted: (_) => save(),
),
if (busy) const Padding(padding: EdgeInsets.only(top: 8), child: LinearProgressIndicator()),
]),
),
actions: [
TextButton(onPressed: busy ? null : () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: busy ? null : save, child: Text(tr('저장'))),
],
);
},
),
);
a.dispose();
b.dispose();
// 처음 정하면 묻는 때는 "비밀번호가 저장된 기능을 쓸 때" (안전 저장소에 값이 없으면 그렇게 본다 - [MasterLock.ask])
return ok == true;
}
/// 묻는 창을 띄울 화면 ([attachMasterPrompt])
GlobalKey<NavigatorState>? _masterNav;
/// 앱을 켤 때 (설정을 읽은 바로 뒤, 실시간 동기화 등이 서버에 붙기 전): 마스터 잠금을 만들고
/// (안전 저장소의 해시 읽기) 저장된 비밀번호를 쓰는 곳들이 지나는 문에 건다.
/// [secrets]: 설정 저장소가 없는 창 (재생만 하는 두 번째 창) 이 쓰는 안전 저장소
Future<MasterLock> setupMasterLock(AppController c, {SecretStore? secrets}) async {
final lock = MasterLock(secrets ?? c.settingsStore?.secrets ?? SecretStore.platform());
try {
await lock.load();
} catch (_) {}
MasterLock.instance = lock;
MasterLock.prompt = (reason) async {
// 화면이 아직 없으면 (켜는 중) 뜰 때까지 조금 기다린다
for (var i = 0; i < 60; i++) {
final ctx = _masterNav?.currentContext;
if (ctx != null && ctx.mounted) return askMaster(ctx, c, lock, reason: reason);
await Future<void>.delayed(const Duration(milliseconds: 250));
}
return false;
};
SecretGate.check = (force) => lock.ensure(force: force);
return lock;
}
/// 마스터를 묻는 창을 띄울 화면
void attachMasterPrompt(GlobalKey<NavigatorState> navigatorKey) => _masterNav = navigatorKey;
/// "처음 시작 시" 로 정해 두었으면 켤 때 한 번 묻는다. 배경 작업 (실시간 동기화 등) 과 같은 창 하나로 (128).
/// 취소해도 앱은 쓸 수 있고, 이번 실행 동안 배경 작업은 묻지 않고 건너뛴다 (사용자가 직접 누르면 다시 묻는다).
Future<void> askMasterAtStartup(BuildContext context, AppController c) async {
final lock = MasterLock.instance;
if (lock == null || !lock.needsPrompt(startup: true) || lock.ask != 'startup') return;
await lock.ensure(force: true);
}