설정 A 분야 11: 탐색기 도구 (ExplorerToolSettings) - 시스템 폴더 · 루트 · Wi-Fi 공유 · ZIP 수준, 키 6

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-22 23:32:25 +09:00
parent a35293271e
commit 828d978812
11 changed files with 79 additions and 54 deletions

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/explorer_tool_settings.dart';
import 'settings/vault_settings.dart';
import 'settings/run_settings.dart';
import 'settings/browser_settings.dart';
@ -29,6 +30,7 @@ import 'settings/explorer_settings.dart';
export 'settings/records.dart';
export 'settings/settings_group.dart';
export 'settings/settings_store.dart';
export 'settings/explorer_tool_settings.dart';
export 'settings/vault_settings.dart';
export 'settings/run_settings.dart';
export 'settings/browser_settings.dart';
@ -159,11 +161,6 @@ class AppSettings {
/// X-plore 비교 5-2 시스템 폴더 보기 (Shizuku, 읽기 전용, 사용자 결정 10/21 · 관리자 결정: 처음 끔 · 켤 때마다 경고)
bool shizukuEnabled = false;
/// X-plore 비교 5-2 루트 폴더 보기 (su, 읽기 전용, 관리자 결정 10/21: Shizuku 와 같은 틀 - 처음 끔 · 켤 때 · 처음 열 때 경고)
bool rootEnabled = false;
@ -171,17 +168,10 @@ class AppSettings {
/// 89: Wi-Fi 공유 - 저절로 끄는 시간 (분, 0 = 끄지 않음). 처음 값 30분 (관리자 지시 10/17)
int wifiShareMinutes = 30;
/// 89 · 보안 검토 3: Wi-Fi 공유를 Tailscale (100.64/10, 내 tailnet 의 기기) 주소에서도 연다 (관리자 결정 10/18: 처음 켬)
bool wifiShareTailscale = true;
/// 89: Wi-Fi 공유로 마지막에 고른 폴더 (다시 켤 때 처음 고른 것)
String wifiShareFolder = '';
/// 86: ZIP 으로 묶기의 압축 정도 (1 빠르게 · 6 보통 (처음 값) · 9 작게) - 마지막에 고른 것을 기억
int zipLevel = 6;
@ -359,8 +349,11 @@ class AppSettings {
/// 암호 보관함
final vault = VaultSettings();
/// 탐색기 도구 - 시스템 폴더 (Shizuku) · 루트 · Wi-Fi 공유 · ZIP 묶기
final tools = ExplorerToolSettings();
/// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다
List<SettingsGroup> get groups => [explorer, network, tor, copy, aiImage, reader, download, browser, run, vault];
List<SettingsGroup> get groups => [explorer, network, tor, copy, aiImage, reader, download, browser, run, vault, tools];
/// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다
Map<String, Object?> toJson({bool plainSecrets = false}) {
@ -398,12 +391,6 @@ class AppSettings {
'appIcon': appIcon,
'uiLanguagesAdded': uiLanguagesAdded,
'openFileWindow': openFileWindow,
'zipLevel': zipLevel,
'wifiShareMinutes': wifiShareMinutes,
'wifiShareTailscale': wifiShareTailscale,
'wifiShareFolder': wifiShareFolder,
'shizukuEnabled': shizukuEnabled,
'rootEnabled': rootEnabled,
'mkvExisting': mkvExisting,
'components': components,
'aiImageV1': true,
@ -492,12 +479,6 @@ class AppSettings {
? j['screenOrientation'] as String
: 'auto'
..openFileWindow = j['openFileWindow'] == 'new' ? 'new' : 'same'
..shizukuEnabled = j['shizukuEnabled'] == true
..rootEnabled = j['rootEnabled'] == true
..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
..wifiShareFolder = j['wifiShareFolder'] is String ? j['wifiShareFolder'] as String : ''
..mkvExisting = const ['rename', 'overwrite', 'skip'].contains(j['mkvExisting']) ? j['mkvExisting'] as String : 'ask'
..components = j['components'] is List
? [

View File

@ -0,0 +1,44 @@
import 'settings_group.dart';
/// 약속 (설정 A): 환경 설정 > 탐색기 도구 - 시스템 폴더 (Shizuku) · 루트 · Wi-Fi 공유 · ZIP 묶기 - 자기 키 (이름 · 처음 값 · 읽기 · 쓰기) 를 스스로 갖는다.
/// 설정 하나를 더할 때는 이 파일만 고친다. 저장 파일의 키 이름은 예전 그대로 (평평한 키)
class ExplorerToolSettings implements SettingsGroup {
/// X-plore 비교 5-2 시스템 폴더 보기 (Shizuku, 읽기 전용, 사용자 결정 10/21 · 관리자 결정: 처음 끔 · 켤 때마다 경고)
bool shizukuEnabled = false;
/// X-plore 비교 5-2 루트 폴더 보기 (su, 읽기 전용, 관리자 결정 10/21: Shizuku 와 같은 틀 - 처음 끔 · 켤 때 · 처음 열 때 경고)
bool rootEnabled = false;
/// 89: Wi-Fi 공유 - 저절로 끄는 시간 (분, 0 = 끄지 않음). 처음 값 30분 (관리자 지시 10/17)
int wifiShareMinutes = 30;
/// 89 · 보안 검토 3: Wi-Fi 공유를 Tailscale (100.64/10, 내 tailnet 의 기기) 주소에서도 연다 (관리자 결정 10/18: 처음 켬)
bool wifiShareTailscale = true;
/// 89: Wi-Fi 공유로 마지막에 고른 폴더 (다시 켤 때 처음 고른 것)
String wifiShareFolder = '';
/// 86: ZIP 으로 묶기의 압축 정도 (1 빠르게 · 6 보통 (처음 값) · 9 작게) - 마지막에 고른 것을 기억
int zipLevel = 6;
@override
void write(Map<String, Object?> j, {required bool plainSecrets}) {
j['shizukuEnabled'] = shizukuEnabled;
j['rootEnabled'] = rootEnabled;
j['wifiShareMinutes'] = wifiShareMinutes;
j['wifiShareTailscale'] = wifiShareTailscale;
j['wifiShareFolder'] = wifiShareFolder;
j['zipLevel'] = zipLevel;
}
@override
void read(Map<String, dynamic> j) {
shizukuEnabled = j['shizukuEnabled'] == true;
rootEnabled = j['rootEnabled'] == true;
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;
wifiShareFolder = j['wifiShareFolder'] is String ? j['wifiShareFolder'] as String : '';
}
}

View File

@ -314,7 +314,7 @@ Future<String?> createZipFlow(BuildContext context, AppController c, List<String
}
final name = TextEditingController(
text: sources.length == 1 ? p.basenameWithoutExtension(sources.first) : p.basename(dir.endsWith(p.separator) ? p.dirname(dir) : dir));
var level = c.settings.zipLevel;
var level = c.settings.tools.zipLevel;
var where = dir;
// zip_aes: 비밀번호 (비우면 보통 ZIP). 기억하지 않고, 기록에 남기지 않는다
final pass = TextEditingController();
@ -412,7 +412,7 @@ Future<String?> createZipFlow(BuildContext context, AppController c, List<String
pass.dispose();
pass2.dispose();
if (go != true || !context.mounted) return null;
if (level != c.settings.zipLevel) await c.updateSettings((x) => x.zipLevel = level);
if (level != c.settings.tools.zipLevel) await c.updateSettings((x) => x.tools.zipLevel = level);
if (!context.mounted) return null;
// zip_aes: 비밀번호 ZIP 은 동봉 7-Zip 으로 (흘려 가며 AES-256 - 큰 동영상도 메모리에 다 올리지 않게)
final exe = password.isEmpty ? null : await bundledSevenZip();

View File

@ -88,8 +88,8 @@ class ExplorerController extends ChangeNotifier {
for (final s in c.settings.smbServers) ('$smbScheme${s.id}/', s.label),
// l: 평문 FTP 는 이름 앞에 ⚠
for (final s in c.settings.ftpServers) ('$ftpScheme${s.id}/', s.tls ? s.label : '⚠ ${s.label}'),
if (c.settings.shizukuEnabled && shizukuSupported()) ('$shizukuScheme$shizukuServer/', tr('시스템 (읽기 전용)')),
if (c.settings.rootEnabled && rootSupported()) (rootHome, tr('루트 (읽기 전용)')),
if (c.settings.tools.shizukuEnabled && shizukuSupported()) ('$shizukuScheme$shizukuServer/', tr('시스템 (읽기 전용)')),
if (c.settings.tools.rootEnabled && rootSupported()) (rootHome, tr('루트 (읽기 전용)')),
];
String volumeOf(String path) => vol.volumeOf(path, local);

View File

@ -94,7 +94,7 @@ class _FilesSettingsSectionState extends State<FilesSettingsSection> {
title: Text(tr('시스템 폴더 보기 (Shizuku · 읽기 전용)')),
subtitle: Text(tr('Shizuku 앱이 있으면 Android/data · obb 처럼 막힌 폴더를 읽기만 합니다. 탐색기 위 탭 [시스템 (읽기 전용)]')),
trailing: Switch(
value: s.shizukuEnabled,
value: s.tools.shizukuEnabled,
onChanged: (v) async {
if (v &&
!await confirmReadOnlyView(context,
@ -104,7 +104,7 @@ class _FilesSettingsSectionState extends State<FilesSettingsSection> {
'대화 · 로그인 정보 같은 개인 데이터가 있을 수 있습니다.'))) {
return;
}
await c.updateSettings((x) => x.shizukuEnabled = v);
await c.updateSettings((x) => x.tools.shizukuEnabled = v);
},
),
),
@ -114,7 +114,7 @@ class _FilesSettingsSectionState extends State<FilesSettingsSection> {
title: Text(tr('루트 폴더 보기 (su · 읽기 전용)')),
subtitle: Text(tr('루팅한 기기에서 su 로 시스템 · 다른 앱의 폴더를 읽기만 합니다. 탐색기 위 탭 [루트 (읽기 전용)]')),
trailing: Switch(
value: s.rootEnabled,
value: s.tools.rootEnabled,
onChanged: (v) async {
if (v &&
!await confirmReadOnlyView(context,
@ -124,7 +124,7 @@ class _FilesSettingsSectionState extends State<FilesSettingsSection> {
'(대화 · 로그인 정보 · 비밀번호 등) 도 보일 수 있습니다.'))) {
return;
}
await c.updateSettings((x) => x.rootEnabled = v);
await c.updateSettings((x) => x.tools.rootEnabled = v);
},
),
),

View File

@ -74,10 +74,10 @@ class _WifiSharePageState extends State<WifiSharePage> {
_lastReason = null;
});
try {
final mins = widget.c.settings.wifiShareMinutes;
final mins = widget.c.settings.tools.wifiShareMinutes;
final s = await WifiShare.start(widget.folder,
autoOff: mins > 0 ? Duration(minutes: mins) : null, tailscale: widget.c.settings.wifiShareTailscale);
await widget.c.updateSettings((x) => x.wifiShareFolder = widget.folder);
autoOff: mins > 0 ? Duration(minutes: mins) : null, tailscale: widget.c.settings.tools.wifiShareTailscale);
await widget.c.updateSettings((x) => x.tools.wifiShareFolder = widget.folder);
_s?.removeListener(_changed);
_s = s..addListener(_changed);
// 작업 기록에는 켜고 끈 것만 (코드 · 주소 · 접속한 곳은 남기지 않는다)
@ -109,7 +109,7 @@ class _WifiSharePageState extends State<WifiSharePage> {
}
List<Widget> _offView() {
final mins = widget.c.settings.wifiShareMinutes;
final mins = widget.c.settings.tools.wifiShareMinutes;
return [
Text(tr('공유할 폴더'), style: TextStyle(color: JjColors.textDim, fontSize: 12)),
const SizedBox(height: 4),
@ -125,7 +125,7 @@ class _WifiSharePageState extends State<WifiSharePage> {
],
onChanged: (v) async {
if (v == null) return;
await widget.c.updateSettings((x) => x.wifiShareMinutes = v);
await widget.c.updateSettings((x) => x.tools.wifiShareMinutes = v);
if (mounted) setState(() {});
},
),
@ -135,9 +135,9 @@ class _WifiSharePageState extends State<WifiSharePage> {
contentPadding: EdgeInsets.zero,
title: Text(tr('Tailscale 에서도 열기')),
subtitle: Text(tr('내 Tailscale 망 (tailnet) 의 기기도 이 주소로 볼 수 있습니다. 끄면 같은 Wi-Fi · 유선 망에서만')),
value: widget.c.settings.wifiShareTailscale,
value: widget.c.settings.tools.wifiShareTailscale,
onChanged: (v) async {
await widget.c.updateSettings((x) => x.wifiShareTailscale = v);
await widget.c.updateSettings((x) => x.tools.wifiShareTailscale = v);
if (mounted) setState(() {});
},
),

View File

@ -121,7 +121,7 @@ void main() {
await tester.tap(find.widgetWithText(FilledButton, '묶기'));
await wait(tester, () => finished);
expect(made, p.join(tmp.path, 'photos.zip'));
expect(c.settings.zipLevel, 9, reason: '고른 압축 정도를 기억');
expect(c.settings.tools.zipLevel, 9, reason: '고른 압축 정도를 기억');
expect(File(made!).lengthSync() > 0, isTrue);
});

View File

@ -55,9 +55,9 @@ void main() {
// 설정 "Tailscale 에서도 열기" 를 끄면 Tailscale 주소는 열지 않는다 (처음 값은 켬)
expect(WifiShare.shareInterface('Tailscale', '100.69.29.106', tailscale: false), isFalse);
expect(WifiShare.shareInterface('Wi-Fi', '192.168.0.5', tailscale: false), isTrue);
expect(AppSettings().wifiShareTailscale, isTrue);
expect(AppSettings.fromJson(const {'wifiShareTailscale': false}).wifiShareTailscale, isFalse);
expect(AppSettings.fromJson(const {'wifiShareTailscale': 'x'}).wifiShareTailscale, isTrue, reason: '틀린 값은 처음 값');
expect(AppSettings().tools.wifiShareTailscale, isTrue);
expect(AppSettings.fromJson(const {'wifiShareTailscale': false}).tools.wifiShareTailscale, isFalse);
expect(AppSettings.fromJson(const {'wifiShareTailscale': 'x'}).tools.wifiShareTailscale, isTrue, reason: '틀린 값은 처음 값');
});
test('11: mDNS 로 찾은 주소는 사설망만', () {

View File

@ -129,7 +129,7 @@ void main() {
AppController ctl({bool on = true}) {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..rootEnabled = on
..tools.rootEnabled = on
..explorer.paths = [tmp.path, tmp.path]
..explorer.layout = 'single';
return c;
@ -221,9 +221,9 @@ void main() {
await t.pump();
await t.tap(find.widgetWithText(FilledButton, '켜기'));
await settle();
expect(c.settings.rootEnabled, isTrue);
expect(c.settings.tools.rootEnabled, isTrue);
await t.tap(sw.first);
await settle();
expect(c.settings.rootEnabled, isFalse);
expect(c.settings.tools.rootEnabled, isFalse);
});
}

View File

@ -112,7 +112,7 @@ void main() {
AppController ctl({bool on = true}) {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..shizukuEnabled = on
..tools.shizukuEnabled = on
..explorer.paths = [tmp.path, tmp.path]
..explorer.layout = 'single';
return c;
@ -231,16 +231,16 @@ void main() {
expect(t.widget<FilledButton>(find.widgetWithText(FilledButton, '켜기')).onPressed, isNull, reason: '체크해야 켜짐');
await t.tap(find.text('취소'));
await settle();
expect(c.settings.shizukuEnabled, isFalse);
expect(c.settings.tools.shizukuEnabled, isFalse);
await t.tap(sw.first);
await settle();
await t.tap(find.text('위 내용을 알고 켭니다'));
await t.pump();
await t.tap(find.widgetWithText(FilledButton, '켜기'));
await settle();
expect(c.settings.shizukuEnabled, isTrue);
expect(c.settings.tools.shizukuEnabled, isTrue);
await t.tap(sw.first);
await settle();
expect(c.settings.shizukuEnabled, isFalse, reason: '끄기는 묻지 않음');
expect(c.settings.tools.shizukuEnabled, isFalse, reason: '끄기는 묻지 않음');
});
}

View File

@ -29,7 +29,7 @@ void main() {
testWidgets('꺼짐 → [켜기] → 코드 · 남은 시간 → [끄기] → 꺼짐', (tester) async {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
expect(c.settings.wifiShareMinutes, 30, reason: '처음 값 30분 (관리자 지시)');
expect(c.settings.tools.wifiShareMinutes, 30, reason: '처음 값 30분 (관리자 지시)');
// 폰 세로처럼 좁고 긴 화면 (아래 줄까지 다 그려지게)
tester.view.physicalSize = const Size(412, 1600);
tester.view.devicePixelRatio = 1;
@ -49,7 +49,7 @@ void main() {
expect(find.text(s.code), findsOneWidget);
expect(find.textContaining('분 뒤에 저절로 꺼집니다'), findsOneWidget);
expect(find.text('아직 접속한 기기가 없습니다'), findsOneWidget);
expect(c.settings.wifiShareFolder, tmp.path);
expect(c.settings.tools.wifiShareFolder, tmp.path);
await tester.tap(find.text('끄기'));
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));