설정 A 분야 10: 암호 보관함 (VaultSettings) - 키 3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-22 23:30:18 +09:00
parent 056ca186d9
commit a35293271e
10 changed files with 54 additions and 32 deletions

View File

@ -26,7 +26,7 @@ void main() {
final tmp = Directory.systemTemp.createTempSync('jj_vaultshot_');
addTearDown(() => tmp.deleteSync(recursive: true));
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.vaultEnabled = true;
c.settings.vault.enabled = true;
final key = GlobalKey();
Future<void> shot(String name, Size size, Directory dir) async {

View File

@ -15,6 +15,7 @@ import '../services/app_shell.dart' show appIconOf;
import '../core/remote_idle.dart';
import 'settings/records.dart';
import 'settings/settings_group.dart';
import 'settings/vault_settings.dart';
import 'settings/run_settings.dart';
import 'settings/browser_settings.dart';
import 'settings/download_settings.dart';
@ -28,6 +29,7 @@ import 'settings/explorer_settings.dart';
export 'settings/records.dart';
export 'settings/settings_group.dart';
export 'settings/settings_store.dart';
export 'settings/vault_settings.dart';
export 'settings/run_settings.dart';
export 'settings/browser_settings.dart';
export 'settings/download_settings.dart';
@ -156,9 +158,6 @@ class AppSettings {
/// X-plore 비교 5-2 암호 보관함 (사용자 결정 10/21, 관리자 결정): 처음 끔. 보관함 자체는 설정이 아니라 그 폴더 (vault.json · *.jjv) 에 있다 -
/// 설정을 처음 상태로 돌려도 보관함은 그대로 (끄기만 됨)
bool vaultEnabled = false;
/// X-plore 비교 5-2 시스템 폴더 보기 (Shizuku, 읽기 전용, 사용자 결정 10/21 · 관리자 결정: 처음 끔 · 켤 때마다 경고)
bool shizukuEnabled = false;
@ -166,11 +165,7 @@ class AppSettings {
/// X-plore 비교 5-2 루트 폴더 보기 (su, 읽기 전용, 관리자 결정 10/21: Shizuku 와 같은 틀 - 처음 끔 · 켤 때 · 처음 열 때 경고)
bool rootEnabled = false;
/// [마스터로도 열기] - 보관함 열쇠 한 벌을 안전 저장소 (마스터 비밀번호로 막힘) 에 둔다. 처음 끔. 마스터를 초기화하면 그 한 벌은 지워진다
bool vaultMasterCopy = false;
/// 앱이 뒤로 간 뒤 이만큼 (초) 지나면 다시 잠금 (처음 60)
int vaultAutoLockSeconds = 60;
@ -361,8 +356,11 @@ class AppSettings {
/// 실행 · 종료 · 업데이트
final run = RunSettings();
/// 암호 보관함
final vault = VaultSettings();
/// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다
List<SettingsGroup> get groups => [explorer, network, tor, copy, aiImage, reader, download, browser, run];
List<SettingsGroup> get groups => [explorer, network, tor, copy, aiImage, reader, download, browser, run, vault];
/// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다
Map<String, Object?> toJson({bool plainSecrets = false}) {
@ -404,11 +402,8 @@ class AppSettings {
'wifiShareMinutes': wifiShareMinutes,
'wifiShareTailscale': wifiShareTailscale,
'wifiShareFolder': wifiShareFolder,
'vaultEnabled': vaultEnabled,
'shizukuEnabled': shizukuEnabled,
'rootEnabled': rootEnabled,
'vaultMasterCopy': vaultMasterCopy,
'vaultAutoLockSeconds': vaultAutoLockSeconds,
'mkvExisting': mkvExisting,
'components': components,
'aiImageV1': true,
@ -497,11 +492,8 @@ class AppSettings {
? j['screenOrientation'] as String
: 'auto'
..openFileWindow = j['openFileWindow'] == 'new' ? 'new' : 'same'
..vaultEnabled = j['vaultEnabled'] == true
..shizukuEnabled = j['shizukuEnabled'] == true
..rootEnabled = j['rootEnabled'] == true
..vaultMasterCopy = j['vaultMasterCopy'] == true
..vaultAutoLockSeconds = j['vaultAutoLockSeconds'] is num ? (j['vaultAutoLockSeconds'] as num).round().clamp(0, 3600) : 60
..zipLevel = const [1, 6, 9].contains(j['zipLevel']) ? j['zipLevel'] as int : 6
..wifiShareMinutes = const [0, 10, 30, 60, 120].contains(j['wifiShareMinutes']) ? j['wifiShareMinutes'] as int : 30
..wifiShareTailscale = j['wifiShareTailscale'] is bool ? j['wifiShareTailscale'] as bool : true

View File

@ -0,0 +1,30 @@
import 'settings_group.dart';
/// 약속 (설정 A): 환경 설정 > 암호 보관함 - 자기 키 (이름 · 처음 값 · 읽기 · 쓰기) 를 스스로 갖는다.
/// 설정 하나를 더할 때는 이 파일만 고친다. 저장 파일의 키 이름은 예전 그대로 (평평한 키)
class VaultSettings implements SettingsGroup {
/// X-plore 비교 5-2 암호 보관함 (사용자 결정 10/21, 관리자 결정): 처음 끔. 보관함 자체는 설정이 아니라 그 폴더 (vault.json · *.jjv) 에 있다 -
/// 설정을 처음 상태로 돌려도 보관함은 그대로 (끄기만 됨)
bool enabled = false;
/// [마스터로도 열기] - 보관함 열쇠 한 벌을 안전 저장소 (마스터 비밀번호로 막힘) 에 둔다. 처음 끔. 마스터를 초기화하면 그 한 벌은 지워진다
bool masterCopy = false;
/// 앱이 뒤로 간 뒤 이만큼 (초) 지나면 다시 잠금 (처음 60)
int autoLockSeconds = 60;
@override
void write(Map<String, Object?> j, {required bool plainSecrets}) {
j['vaultEnabled'] = enabled;
j['vaultMasterCopy'] = masterCopy;
j['vaultAutoLockSeconds'] = autoLockSeconds;
}
@override
void read(Map<String, dynamic> j) {
enabled = j['vaultEnabled'] == true;
masterCopy = j['vaultMasterCopy'] == true;
autoLockSeconds = j['vaultAutoLockSeconds'] is num ? (j['vaultAutoLockSeconds'] as num).round().clamp(0, 3600) : 60;
}
}

View File

@ -215,7 +215,7 @@ class ExplorerButtons {
case ExplorerButton.apps:
await openAppManager(cmd.context);
case ExplorerButton.vault:
if (!ctl.c.settings.vaultEnabled) {
if (!ctl.c.settings.vault.enabled) {
ctl.host.snack(tr('암호 보관함은 환경 설정 > 보안 에서 켭니다 (처음 꺼짐)'));
} else {
await openVault(cmd.context, ctl.c);

View File

@ -106,7 +106,7 @@ class ExplorerItemMenu {
// X-plore 비교 5-1 ④ (관리자 지시 10/21): SQLite (읽기 전용)
if (!e.isDir && !inArchive && sqliteExtensions.contains(ext)) ('sqlite', Icons.table_chart_outlined, tr('SQLite 보기 (읽기 전용)')),
// X-plore 비교 5-2 (사용자 결정 10/21): 암호 보관함에 넣기. 2단계 (관리자 지시 10/21): 폴더째 (안의 구조 그대로) · 원격 파일도 (받아서 넣음)
if (ctl.c.settings.vaultEnabled && !inArchive)
if (ctl.c.settings.vault.enabled && !inArchive)
('vaultAdd', Icons.enhanced_encryption_outlined, many ? trf('{0}개 보관함에 넣기', [targets.length]) : tr('보관함에 넣기')),
if (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')),
if (!e.isDir && !video) ('openDefault', Icons.launch, tr('기본 앱으로 열기')),

View File

@ -217,7 +217,7 @@ Future<void> showSuperReset(BuildContext context, AppController c, MasterLock lo
'${tr('다른 판에서 만든 설정도 지웁니다 (다른 판이 넣은 비밀번호는 [저장된 실제 비밀번호 · API 키] 를 함께 골라야 지워집니다).')}'),
),
// X-plore 비교 5-2 (관리자 결정 10/21): 보관함은 초기화 대상이 아님 (풀 수 있는 열쇠가 없음)
if (c.settings.vaultEnabled)
if (c.settings.vault.enabled)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(tr('암호 보관함은 초기화되지 않습니다 - 보관함 파일은 그대로 남고 보관함 비밀번호로만 열 수 있습니다. [마스터로도 열기] 를 켰다면 마스터를 초기화할 때 그 길만 없어집니다'),

View File

@ -125,9 +125,9 @@ class SecuritySettings extends StatelessWidget {
leading: const Icon(Icons.enhanced_encryption_outlined),
title: Text(tr('암호 보관함')),
subtitle: Text(tr('파일을 AES-256 으로 암호화해 보관합니다 (이름까지 숨김). 보관함 비밀번호를 잊으면 되살릴 수 없습니다')),
trailing: Switch(value: c.settings.vaultEnabled, onChanged: (v) => c.updateSettings((x) => x.vaultEnabled = v)),
trailing: Switch(value: c.settings.vault.enabled, onChanged: (v) => c.updateSettings((x) => x.vault.enabled = v)),
),
if (c.settings.vaultEnabled) ...[
if (c.settings.vault.enabled) ...[
ListTile(
contentPadding: const EdgeInsets.only(left: 56, right: 16),
title: Text(tr('보관함 열기')),
@ -139,12 +139,12 @@ class SecuritySettings extends StatelessWidget {
title: Text(tr('보관함 자동 잠금')),
subtitle: Text(tr('앱이 뒤로 간 뒤 이만큼 지나 돌아오면 다시 비밀번호를 묻습니다')),
trailing: DropdownButton<int>(
value: c.settings.vaultAutoLockSeconds,
value: c.settings.vault.autoLockSeconds,
items: [
for (final v in {0, 30, 60, 300, 900, c.settings.vaultAutoLockSeconds}.toList()..sort())
for (final v in {0, 30, 60, 300, 900, c.settings.vault.autoLockSeconds}.toList()..sort())
DropdownMenuItem(value: v, child: Text(v == 0 ? tr('바로') : v == 60 ? tr('1분 (기본)') : v < 60 ? trf('{0}초', [v]) : trf('{0}분', [v ~/ 60]))),
],
onChanged: (v) => c.updateSettings((x) => x.vaultAutoLockSeconds = v!),
onChanged: (v) => c.updateSettings((x) => x.vault.autoLockSeconds = v!),
),
),
],

View File

@ -330,6 +330,6 @@ Future<void> vaultToggleMasterCopy(BuildContext context, AppController c, VaultU
} else {
await lock.secrets.delete(MasterLock.vaultCopyKey);
}
await c.updateSettings((x) => x.vaultMasterCopy = on);
await c.updateSettings((x) => x.vault.masterCopy = on);
if (context.mounted) s.changed();
}

View File

@ -104,7 +104,7 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
} else if (state == AppLifecycleState.resumed) {
final at = _hiddenAt;
_hiddenAt = null;
if (at != null && _s.vault != null && DateTime.now().difference(at).inSeconds >= c.settings.vaultAutoLockSeconds) {
if (at != null && _s.vault != null && DateTime.now().difference(at).inSeconds >= c.settings.vault.autoLockSeconds) {
setState(_lock);
}
}
@ -291,13 +291,13 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
onSelected: (w) => switch (w) {
'mkdir' => vaultMakeFolder(context, _s),
'pw' => vaultChangePassword(context, _s, iterations: vaultIterationsForTest),
'master' => vaultToggleMasterCopy(context, c, _s, !c.settings.vaultMasterCopy),
'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.vaultMasterCopy, child: Text(tr('마스터로도 열기'))),
CheckedPopupMenuItem(value: 'master', checked: c.settings.vault.masterCopy, child: Text(tr('마스터로도 열기'))),
],
),
],
@ -322,7 +322,7 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
pw: _pw,
waiting: _waitUntil != null && DateTime.now().isBefore(_waitUntil!),
error: _error,
masterCopy: c.settings.vaultMasterCopy,
masterCopy: c.settings.vault.masterCopy,
onUnlock: _unlock,
onUnlockWithMaster: _unlockWithMaster,
),

View File

@ -48,7 +48,7 @@ void main() {
AppController ctl() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.vaultEnabled = true;
c.settings.vault.enabled = true;
return c;
}
@ -170,7 +170,7 @@ void main() {
await settle(t);
expect(find.textContaining('마스터를 초기화하면 이 길만 없어지고'), findsOneWidget);
await t.tap(find.widgetWithText(FilledButton, '켜기'));
await until(t, () => c.settings.vaultMasterCopy);
await until(t, () => c.settings.vault.masterCopy);
expect((await t.runAsync(secrets.readAll))!.containsKey(MasterLock.vaultCopyKey), isTrue);
// 잠그고 마스터로 열기
await t.tap(find.byTooltip('잠그기'));
@ -214,7 +214,7 @@ void main() {
Directory(p.join(dir.path, 'sub')).createSync();
for (final on in [false, true]) {
final c = ctl()
..settings.vaultEnabled = on
..settings.vault.enabled = on
..settings.explorer.paths = [dir.path, dir.path]
..settings.explorer.layout = 'split';
await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), c: c))));
@ -287,7 +287,7 @@ void main() {
viewRoot.existsSync() ? viewRoot.listSync(recursive: true).map((e) => e.path).toList() : const <String>[];
final photo = File(p.join(tmp.path, 'photo.png'))..writeAsBytesSync(img.encodePng(img.Image(width: 20, height: 10)));
final clip = File(p.join(tmp.path, 'clip.mp4'))..writeAsBytesSync(List.filled(500, 1));
final c = ctl()..settings.vaultAutoLockSeconds = 0;
final c = ctl()..settings.vault.autoLockSeconds = 0;
await t.pumpWidget(MaterialApp(home: VaultPage(c: c, add: [photo.path, clip.path])));
await until(t, () => find.text('새 보관함 만들기').evaluate().isNotEmpty);
await t.enterText(find.byType(TextField).at(0), 'pw');