From 36069a87078598ae680649b5b1ad6913acbac7ba Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:19:59 +0900 Subject: [PATCH] =?UTF-8?q?W=20JjField=201:=20=EB=B9=84=EB=B0=80=20?= =?UTF-8?q?=EC=B9=B8=206=EA=B3=B3=EC=9D=84=20SecretField=20=ED=95=98?= =?UTF-8?q?=EB=82=98=EB=A1=9C=20+=20=EC=BA=A1=EC=B2=98=20=ED=9D=94?= =?UTF-8?q?=EB=93=A4=EB=A6=BC=20(check=5Fshot=2037=C2=B765=C2=B794=20?= =?UTF-8?q?=C2=B7=20sshkey)=20=EC=9D=84=20=EC=8B=9C=ED=97=98=20=EC=AA=BD?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/ui/secret_field.dart: 비밀번호 · API 키 · 키 암호 칸 (처음 늘 가림, 눈 단추로 켜고 끄기, 자동 고침 · 추천 끔, 바깥 가리기로 칸 여럿을 단추 하나로). WebDAV · FTP · SFTP · SMB · AI 서비스 · 압축이 부르기만 한다 - 눈 단추 모양 · 툴팁을 하나로 (AI 창의 채운 24 아이콘 → 다른 곳과 같은 테두리 18, 툴팁 '보이기/가리기'), 쓰지 않게 된 사전 글 '비밀번호 보기/숨기기' 를 en · ja · zh 에서 뺌 - 시험: secret_field_contract_test (처음 가림 · 켜고 끄기 · 칸 여럿 · 300/412 · 배율 2 넘침 없음) - 시험 쪽 고정: testFixedTempDir · pinTestTimes (폴더 이름 · 파일 · 폴더 시각), 시험 SFTP 서버 fixedPort · fixedHostKey Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 2 - assets/l10n/ja.json | 2 - assets/l10n/zh-Hans.json | 2 - integration_test/support/temp_dir.dart | 31 +++++++ integration_test/windows_check_shot_test.dart | 15 ++-- .../windows_sshkey_shot_test.dart | 5 +- lib/ui/ai_settings.dart | 18 ++-- lib/ui/archive_ui.dart | 38 +++------ lib/ui/ftp_settings.dart | 15 +--- lib/ui/secret_field.dart | 71 ++++++++++++++++ lib/ui/sftp_settings.dart | 30 +++---- lib/ui/smb_settings.dart | 15 +--- lib/ui/webdav_settings.dart | 14 +--- test/secret_field_contract_test.dart | 82 +++++++++++++++++++ test/support/sftp_server.dart | 5 ++ tool/sftp_test_server.py | 15 +++- 16 files changed, 249 insertions(+), 111 deletions(-) create mode 100644 lib/ui/secret_field.dart create mode 100644 test/secret_field_contract_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 0d6782b..1fdf346 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -1609,8 +1609,6 @@ "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "Passwords and the subtitle API key aren't backed up, so please enter them again. Auto Backup runs about once a day, so changes made after it may be missing.", "비밀번호 기억 (안전 저장소)": "Remember passwords (secure storage)", "비밀번호 다시 넣기": "Enter password again", - "비밀번호 보기": "Show password", - "비밀번호 숨기기": "Hide password", "비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "Can't preview a password-protected archive - use [Extract]", "비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "Some files are password-protected (you'll be asked when extracting)", "비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "This file is password-protected - use [Extract]", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 49d787d..ae4c4a8 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -1609,8 +1609,6 @@ "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "パスワード・字幕 API キーはバックアップされないので、もう一度入力してください。自動バックアップは 1 日に 1 回程度作成されるため、その後に変更したものは含まれていない場合があります。", "비밀번호 기억 (안전 저장소)": "パスワードを記憶 (安全な保存領域)", "비밀번호 다시 넣기": "パスワードをもう一度入力", - "비밀번호 보기": "パスワードを表示", - "비밀번호 숨기기": "パスワードを隠す", "비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "パスワード付きアーカイブの中はプレビューできません - [展開] で展開してください", "비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "パスワード付きのファイルがあります (展開時に確認します)", "비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "パスワード付きのファイルです - [展開] で展開してください", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 50f1979..b41c993 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -1609,8 +1609,6 @@ "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "密码 · 字幕 API 密钥不会备份,请重新输入。自动备份大约每天创建一次,之后更改的内容可能缺失。", "비밀번호 기억 (안전 저장소)": "记住密码 (安全存储)", "비밀번호 다시 넣기": "再次输入密码", - "비밀번호 보기": "显示密码", - "비밀번호 숨기기": "隐藏密码", "비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "无法预览有密码的压缩包 - 请用 [解压]", "비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "部分文件设有密码 (解压时会询问)", "비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "此文件有密码 - 请用 [解压]", diff --git a/integration_test/support/temp_dir.dart b/integration_test/support/temp_dir.dart index 1c5a509..af86d33 100644 --- a/integration_test/support/temp_dir.dart +++ b/integration_test/support/temp_dir.dart @@ -49,3 +49,34 @@ void deleteTestTemp(Directory dir) { } stderr.writeln('시험 임시 폴더를 지우지 못함: $path'); } + +/// 관리자 지시 (10/24): 캡처 비교가 흔들리지 않게 이름이 늘 같은 시험 폴더 ([testTempDir] 는 끝에 임의 글자가 붙어 경로 막대 · 공유 경로가 실행마다 달랐다). +/// 있으면 지우고 새로 만들고, 시험 끝 (실패해도) 에 지운다. [base] 는 [testTempDir] 와 같다. 반드시 시험 안에서 부른다 +Directory testFixedTempDir(String name, {String? base}) { + final root = base == null || base.isEmpty ? Directory.systemTemp : (Directory(base)..createSync(recursive: true)); + final dir = Directory('${root.path}${Platform.pathSeparator}$name'); + deleteTestTemp(dir); + dir.createSync(recursive: true); + addTearDown(() => deleteTestTemp(dir)); + if (base == null || base.isEmpty) expectInTaggedTemp(dir); + return dir; +} + +/// 캡처용: [dir] 아래 파일 · 폴더의 바뀐 시각을 [at] 으로 고정 (목록의 날짜 칸이 실행마다 같게). +/// Dart 는 폴더 시각을 못 바꿔 폴더는 PowerShell 로 (Windows 만). 긴 경로는 \\?\ 로 +void pinTestTimes(Directory dir, {DateTime? at}) { + final when = at ?? DateTime(2026, 10, 22, 9); + final path = dir.absolute.path; + final long = Platform.isWindows && !path.startsWith(r'\\') ? Directory('\\\\?\\$path') : dir; + for (final f in long.listSync(recursive: true).whereType()) { + f.setLastModifiedSync(when); + } + if (!Platform.isWindows) return; + String two(int v) => v.toString().padLeft(2, '0'); + final stamp = '${when.year}-${two(when.month)}-${two(when.day)} ${two(when.hour)}:${two(when.minute)}'; + Process.runSync('powershell', [ + '-NoProfile', + '-Command', + "Get-ChildItem -LiteralPath '$path' -Recurse -Directory -Force | ForEach-Object { \$_.LastWriteTime = [datetime]'$stamp' }", + ]); +} diff --git a/integration_test/windows_check_shot_test.dart b/integration_test/windows_check_shot_test.dart index 7881d67..4593820 100644 --- a/integration_test/windows_check_shot_test.dart +++ b/integration_test/windows_check_shot_test.dart @@ -34,11 +34,9 @@ void main() { // ⑦ 밝은 화면 캡처: JJ_SHOT_THEME=light (JJ_SHOT_DIR 를 따로 두어 어두운 것과 견줌) if (Platform.environment['JJ_SHOT_THEME'] == 'light') JjColors.use(Brightness.light); final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots'); - // 시험 폴더: 항목이 아주 많은 시스템 임시 폴더 대신 (JJ_SHOT_TMP, 없으면 시스템 임시 폴더) - Directory tempDir(String prefix) { - final base = Platform.environment['JJ_SHOT_TMP']; - return testTempDir(prefix, base: base); - } + // 시험 폴더: 항목이 아주 많은 시스템 임시 폴더 대신 (JJ_SHOT_TMP, 없으면 시스템 임시 폴더). + // 관리자 지시 (10/24): 이름은 늘 같게 (경로 막대 · 공유 경로가 실행마다 달라 37 · 65 · 94 캡처가 흔들렸다) - 파일 · 폴더 시각은 [pinTestTimes] + Directory tempDir(String name) => testFixedTempDir(name, base: Platform.environment['JJ_SHOT_TMP']); final key = GlobalKey(); const sizes = [('narrow', Size(700, 900)), ('wide', Size(1280, 800))]; @@ -103,7 +101,7 @@ void main() { } testWidgets('65 · 94 · 98: 지우기 - 휴지통 · 영구 삭제 · 휴지통 없는 위치 · 오류 문구', (t) async { - final tmp = tempDir('jj_shot_del_'); + final tmp = tempDir('jj_shot_del_fixed'); final started = DateTime.now(); // 휴지통: 기본은 가짜 (화면 흐름만), JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통 (끝에 시험 항목만 지움) final device = TestExplorerDevice(); @@ -113,6 +111,7 @@ void main() { final dir = Directory(p.join(tmp.path, tag))..createSync(); File(p.join(dir.path, 'recycle_me_$tag.txt')).writeAsStringSync('x'); File(p.join(dir.path, 'keep_$tag.txt')).writeAsStringSync('x'); + pinTestTimes(dir); final c = controller(); c.settings ..explorer.style = 'xplore' // 이 캡처는 X-plore 모양 (E6 부터 Windows 처음 값은 Total Commander - 이름 · 확장자가 다른 칸) @@ -142,6 +141,7 @@ void main() { final unc = '\\\\localhost\\${tmp.path[0]}\$${tmp.path.substring(2)}'; final share = Directory(p.join(unc, 'share'))..createSync(); File(p.join(share.path, 'on_share.txt')).writeAsStringSync('x'); + pinTestTimes(share); for (final (tag, size) in sizes) { final c = controller(); c.settings @@ -206,10 +206,11 @@ void main() { }); testWidgets('37: 경로 입력 - 폴더로 가기 · \\\\NAS 만 · 없는 경로', (t) async { - final tmp = tempDir('jj_shot_path_'); + final tmp = tempDir('jj_shot_path_fixed'); try { final target = Directory(p.join(tmp.path, '가고 싶은 폴더'))..createSync(); File(p.join(target.path, 'inside.txt')).writeAsStringSync('x'); + pinTestTimes(tmp); for (final (tag, size) in sizes) { final c = controller(); c.settings diff --git a/integration_test/windows_sshkey_shot_test.dart b/integration_test/windows_sshkey_shot_test.dart index 56112e5..f951e26 100644 --- a/integration_test/windows_sshkey_shot_test.dart +++ b/integration_test/windows_sshkey_shot_test.dart @@ -43,7 +43,8 @@ void main() { addTearDown(t.view.reset); await t.runAsync(() => i18n.apply(lang, save: false)); addTearDown(() => i18n.apply('ko', save: false)); - final tmp = testTempDir('jj_sshkeyshot_'); + // 관리자 지시 (10/24): 폴더 이름 · 포트 · 서버 키 지문을 실행마다 같게 (캡처가 흔들리지 않게 - 시험 쪽에서만) + final tmp = testFixedTempDir('jj_sshkeyshot_fixed'); final root = Directory(p.join(tmp.path, 'root'))..createSync(); Future gen(String name, String pass) async { final f = p.join(tmp.path, name); @@ -57,7 +58,7 @@ void main() { await t.runAsync(() async { keyA = await gen('a', 'kp'); keyB = await gen('b', ''); - server = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'hostkey'), password: null, pubkey: '$keyA.pub'); + server = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'hostkey'), password: null, pubkey: '$keyA.pub', fixedPort: 42022, fixedHostKey: true); }); addTearDown(() => t.runAsync(server.stop)); final ppk = File(p.join(tmp.path, 'key.ppk')) diff --git a/lib/ui/ai_settings.dart b/lib/ui/ai_settings.dart index b4957eb..ebaf69d 100644 --- a/lib/ui/ai_settings.dart +++ b/lib/ui/ai_settings.dart @@ -19,6 +19,7 @@ import 'setting_tile.dart'; import 'theme.dart'; import '../app/ai_upscale.dart' show deviceMemTotalMb, upscaleMemoryLines; import 'notice_band.dart'; +import 'secret_field.dart'; /// 기기 안에서 그리는 데 아직 받지 않은 파일 (Windows 는 실행 파일까지, TAESD 포함) - AI 그림 화면 · 받기 화면이 같은 값을 보인다 (139) List aiMissingFiles(AiStore store) => [ @@ -476,7 +477,6 @@ Future editAiService(BuildContext context, AppController c, {AiService? ol final model = TextEditingController(text: old?.model ?? ''); final key = TextEditingController(text: old?.apiKey ?? ''); var kind = old?.kind ?? 'a1111'; - var hide = true; final saved = await showDialog( context: context, builder: (ctx) => StatefulBuilder( @@ -517,17 +517,11 @@ Future editAiService(BuildContext context, AppController c, {AiService? ol helperMaxLines: 3, ), ), - TextField( - controller: key, - obscureText: hide, - decoration: InputDecoration( - // 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다 - labelText: kind == 'a1111' ? tr('API 키 또는 아이디:비밀번호') : tr('API 키'), - helperText: tr('넣지 않아도 됩니다'), - helperMaxLines: 3, - suffixIcon: IconButton(icon: Icon(hide ? Icons.visibility : Icons.visibility_off), onPressed: () => set(() => hide = !hide)), - ), - ), + // 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다 + SecretField( + controller: key, + label: kind == 'a1111' ? tr('API 키 또는 아이디:비밀번호') : tr('API 키'), + helper: tr('넣지 않아도 됩니다')), ]), ), actions: [ diff --git a/lib/ui/archive_ui.dart b/lib/ui/archive_ui.dart index 940faf4..dcf3a49 100644 --- a/lib/ui/archive_ui.dart +++ b/lib/ui/archive_ui.dart @@ -13,6 +13,7 @@ import '../core/vfs.dart'; import '../l10n/tr.dart'; import 'app_actions.dart' show isCompact; import 'theme.dart'; +import 'secret_field.dart'; /// 86: 동봉한 7-Zip. 없으면 null /// - Windows: 실행 파일 옆 7zip\7z.exe (개발 중에는 third_party 의 것) @@ -354,37 +355,24 @@ Future createZipFlow(BuildContext context, AppController c, List set(() => showPass = !showPass), - ), - ), + label: tr('비밀번호'), + helper: tr('넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP'), + isDense: true, + hidden: !showPass, + onToggle: () => set(() => showPass = !showPass), onChanged: (_) => set(() {}), ), if (pass.text.isNotEmpty) ...[ const SizedBox(height: 6), - TextField( + SecretField( controller: pass2, - obscureText: !showPass, - autocorrect: false, - enableSuggestions: false, - decoration: InputDecoration( - labelText: tr('비밀번호 다시 넣기'), - isDense: true, - errorText: pass2.text.isNotEmpty && pass2.text != pass.text ? tr('두 비밀번호가 다릅니다') : null, - ), + label: tr('비밀번호 다시 넣기'), + isDense: true, + hidden: !showPass, + errorText: pass2.text.isNotEmpty && pass2.text != pass.text ? tr('두 비밀번호가 다릅니다') : null, onChanged: (_) => set(() {}), ), const SizedBox(height: 4), diff --git a/lib/ui/ftp_settings.dart b/lib/ui/ftp_settings.dart index 4ca4c17..7c904a7 100644 --- a/lib/ui/ftp_settings.dart +++ b/lib/ui/ftp_settings.dart @@ -10,6 +10,7 @@ import 'setting_tile.dart'; import 'theme.dart'; import 'webdav_settings.dart' show davServerUsers; import 'notice_band.dart'; +import 'secret_field.dart'; /// l: 환경 설정 > 파일 탐색기 > FTP · FTPS: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인). SFTP 와 같은 틀. /// 서버마다 파일 탐색기 · Rsync 화면 위쪽 (WebDAV 옆) 에 탭이 생긴다. 평문 FTP 는 목록 · 탭 · 창에 "암호화 안 됨" 을 늘 보인다 @@ -139,7 +140,6 @@ Future editFtpServer(BuildContext context, AppController c, {FtpServ final root = TextEditingController(text: old?.root ?? ''); var security = old?.security ?? 'explicit'; var fingerprint = old?.certFingerprint ?? ''; - var hide = true; String? result; var passed = false; var testing = false; @@ -246,18 +246,7 @@ Future editFtpServer(BuildContext context, AppController c, {FtpServ if (security == 'none') NoticeBand(icon: Icons.lock_open, color: JjColors.danger, margin: EdgeInsets.zero, title: tr('평문 FTP: 암호화 안 됨'), body: tr('평문 FTP 는 아이디 · 비밀번호 · 파일이 암호화되지 않은 채 오갑니다. 같은 망의 다른 사람이 볼 수 있습니다. 서버가 FTPS 를 못 할 때만, 믿을 수 있는 집 안 망에서만 쓰세요.')), TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디 (비우면 익명)'))), - TextField( - controller: pass, - obscureText: hide, - decoration: InputDecoration( - labelText: tr('비밀번호'), - suffixIcon: IconButton( - tooltip: hide ? tr('보이기') : tr('가리기'), - icon: Icon(hide ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18), - onPressed: () => set(() => hide = !hide), - ), - ), - ), + SecretField(controller: pass, label: tr('비밀번호')), TextField( controller: root, decoration: InputDecoration(labelText: tr('시작 폴더'), helperText: tr('비우면 로그인한 곳 · 예: /volume1/video · 탭의 맨 위가 됩니다'), helperMaxLines: 3), diff --git a/lib/ui/secret_field.dart b/lib/ui/secret_field.dart new file mode 100644 index 0000000..c70e452 --- /dev/null +++ b/lib/ui/secret_field.dart @@ -0,0 +1,71 @@ +/// 약속 (W 공용 위젯, 설계 7-2 JjField - 비밀 칸): 비밀번호 · API 키 · 키 암호 칸 하나. +/// 입력: 글 칸 · 칸 이름 (짧게) · 도움 글 (칸 아래, 3줄까지) · 오류 글 · 가리기 (스스로 들거나 [hidden] · [onToggle] 로 바깥에서). +/// 출력: 가린 글 칸 + 눈 단추 (가렸으면 "보이기", 보이면 "가리기" - 아이콘 · 크기 · 툴팁이 어디서나 같음). 자동 고침 · 추천은 늘 끔. +/// 오류 없음. 쓰는 곳: WebDAV · FTP · SFTP · SMB 서버 설정 · AI 서비스 · 압축 비밀번호. 화면은 부르기만 한다 +library; + +import 'package:flutter/material.dart'; + +import '../l10n/tr.dart'; + +class SecretField extends StatefulWidget { + const SecretField({ + super.key, + required this.controller, + required this.label, + this.helper, + this.errorText, + this.isDense = false, + this.onChanged, + this.hidden, + this.onToggle, + }); + + final TextEditingController controller; + final String label; + final String? helper; + final String? errorText; + final bool isDense; + final ValueChanged? onChanged; + + /// 바깥에서 가리기를 정할 때 (눈 단추 하나로 칸 여럿 - 비밀번호와 확인 칸 등). null 이면 칸이 스스로 든다 + final bool? hidden; + + /// [hidden] 을 바깥에서 줄 때의 눈 단추 (null 이면 이 칸에는 단추 없음) + final VoidCallback? onToggle; + + @override + State createState() => _SecretFieldState(); +} + +class _SecretFieldState extends State { + bool _hidden = true; + + @override + Widget build(BuildContext context) { + final outside = widget.hidden != null; + final hidden = widget.hidden ?? _hidden; + final toggle = outside ? widget.onToggle : () => setState(() => _hidden = !_hidden); + return TextField( + controller: widget.controller, + obscureText: hidden, + autocorrect: false, + enableSuggestions: false, + onChanged: widget.onChanged, + decoration: InputDecoration( + labelText: widget.label, + helperText: widget.helper, + helperMaxLines: 3, + errorText: widget.errorText, + isDense: widget.isDense, + suffixIcon: toggle == null + ? null + : IconButton( + tooltip: hidden ? tr('보이기') : tr('가리기'), + icon: Icon(hidden ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18), + onPressed: toggle, + ), + ), + ); + } +} diff --git a/lib/ui/sftp_settings.dart b/lib/ui/sftp_settings.dart index 3962697..466f763 100644 --- a/lib/ui/sftp_settings.dart +++ b/lib/ui/sftp_settings.dart @@ -11,6 +11,7 @@ import 'confirm.dart'; import 'setting_tile.dart'; import 'theme.dart'; import 'webdav_settings.dart' show davServerUsers; +import 'secret_field.dart'; /// 88: 환경 설정 > 파일 탐색기 > SFTP: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인). /// 서버마다 파일 탐색기 · Rsync 화면 위쪽 (WebDAV 옆) 에 탭이 생긴다. @@ -274,21 +275,14 @@ Future editSftpServer(BuildContext context, AppController c, {SftpS ), ]), TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디'))), - TextField( - controller: pass, - obscureText: hide, - decoration: InputDecoration( - // 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다 - labelText: tr('비밀번호'), - helperText: tr('개인 키만 쓰면 비웁니다'), - helperMaxLines: 3, - suffixIcon: IconButton( - tooltip: hide ? tr('보이기') : tr('가리기'), - icon: Icon(hide ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18), - onPressed: () => set(() => hide = !hide), - ), - ), - ), + // 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다. + // 눈 단추 하나가 비밀번호 · 개인 키 암호를 함께 보이고 가린다 + SecretField( + controller: pass, + label: tr('비밀번호'), + helper: tr('개인 키만 쓰면 비웁니다'), + hidden: hide, + onToggle: () => set(() => hide = !hide)), const SizedBox(height: 8), Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ OutlinedButton.icon( @@ -302,11 +296,7 @@ Future editSftpServer(BuildContext context, AppController c, {SftpS ], ]), if (key.isNotEmpty) - TextField( - controller: passphrase, - obscureText: hide, - decoration: InputDecoration(labelText: tr('개인 키 암호'), helperText: tr('없으면 비웁니다'), helperMaxLines: 3), - ), + SecretField(controller: passphrase, label: tr('개인 키 암호'), helper: tr('없으면 비웁니다'), hidden: hide), TextField( controller: root, decoration: InputDecoration(labelText: tr('시작 폴더'), helperText: tr('비우면 홈 폴더 · 예: /volume1/video · 탭의 맨 위가 됩니다'), helperMaxLines: 3), diff --git a/lib/ui/smb_settings.dart b/lib/ui/smb_settings.dart index 0ae4bf0..acd1047 100644 --- a/lib/ui/smb_settings.dart +++ b/lib/ui/smb_settings.dart @@ -10,6 +10,7 @@ import 'confirm.dart'; import 'setting_tile.dart'; import 'theme.dart'; import 'webdav_settings.dart' show davServerUsers; +import 'secret_field.dart'; /// 88: 환경 설정 > 파일 탐색기 > SMB (Android 만): 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인). /// Windows 는 운영체제가 \\서버\공유 를 연다 (경로 입력 · 네트워크 드라이브) - 사용자 결정 88-1. @@ -124,7 +125,6 @@ Future editSmbServer(BuildContext context, AppController c, {SmbServ final user = TextEditingController(text: old?.user ?? ''); final domain = TextEditingController(text: old?.domain ?? ''); final pass = TextEditingController(text: old?.password ?? ''); - var hide = true; String? result; String? warning; var passed = false; @@ -229,18 +229,7 @@ Future editSmbServer(BuildContext context, AppController c, {SmbServ ), TextField(controller: root, decoration: InputDecoration(labelText: tr('시작 폴더'), helperText: tr('공유 안의 폴더 · 비우면 공유의 맨 위'), helperMaxLines: 3)), TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디 (비우면 게스트)'))), - TextField( - controller: pass, - obscureText: hide, - decoration: InputDecoration( - labelText: tr('비밀번호'), - suffixIcon: IconButton( - tooltip: hide ? tr('보이기') : tr('가리기'), - icon: Icon(hide ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18), - onPressed: () => set(() => hide = !hide), - ), - ), - ), + SecretField(controller: pass, label: tr('비밀번호')), TextField(controller: domain, decoration: InputDecoration(labelText: tr('도메인 · 작업 그룹'), helperText: tr('보통 비워 둡니다'), helperMaxLines: 3)), const SizedBox(height: 8), if (testing) const LinearProgressIndicator(), diff --git a/lib/ui/webdav_settings.dart b/lib/ui/webdav_settings.dart index d346b9d..c553601 100644 --- a/lib/ui/webdav_settings.dart +++ b/lib/ui/webdav_settings.dart @@ -10,6 +10,7 @@ import 'confirm.dart'; import 'file_error.dart'; import 'theme.dart'; import 'setting_tile.dart'; +import 'secret_field.dart'; /// 환경 설정 > 파일 탐색기 > WebDAV: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인). /// 서버마다 파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆에 탭이 생긴다. @@ -106,7 +107,6 @@ Future editDavServer(BuildContext context, AppController c, {DavServ final user = TextEditingController(text: old?.user ?? ''); final pass = TextEditingController(text: old?.password ?? ''); var insecure = old?.insecure ?? false; - var hide = true; String? result; // 연결 확인 결과 var toHttps = false; // 서버가 https 로 옮기라고 함 → [https 로 바꾸기] var passed = false; @@ -185,17 +185,7 @@ Future editDavServer(BuildContext context, AppController c, {DavServ ]), ), TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디'))), - TextField( - controller: pass, - obscureText: hide, - decoration: InputDecoration( - labelText: tr('비밀번호'), - suffixIcon: IconButton( - icon: Icon(hide ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18), - onPressed: () => set(() => hide = !hide), - ), - ), - ), + SecretField(controller: pass, label: tr('비밀번호')), CheckboxListTile( contentPadding: EdgeInsets.zero, value: insecure, diff --git a/test/secret_field_contract_test.dart b/test/secret_field_contract_test.dart new file mode 100644 index 0000000..1f4902b --- /dev/null +++ b/test/secret_field_contract_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/ui/secret_field.dart'; + +/// W 공용 위젯 약속 (SecretField): 처음은 늘 가림 · 눈 단추는 눌러 켜고 끄기 (누르는 동안만이 아님) · 자동 고침 · 추천 끔 · +/// 바깥 가리기 하나로 칸 여럿 · 좁은 폭 · 큰 글자에서 넘치지 않음. 위젯 하나에 대해 한 번만 둔다 (설계 7-2) +void main() { + TextField field(WidgetTester t, int i) => t.widget(find.byType(TextField).at(i)); + EditableText edit(WidgetTester t, int i) => t.widget(find.byType(EditableText).at(i)); + + Future show(WidgetTester t, Widget child, {double width = 412, double scale = 1}) async { + t.view.physicalSize = Size(width, 900); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + home: MediaQuery( + data: MediaQueryData(size: Size(width, 900), textScaler: TextScaler.linear(scale)), + child: Scaffold(body: Column(children: [child])), + ), + )); + } + + testWidgets('스스로 들기: 처음 가림 → 누르면 보임 (손을 떼도 그대로) → 다시 누르면 가림', (t) async { + final c = TextEditingController(text: 'pw'); + await show(t, SecretField(controller: c, label: '비밀번호')); + expect(field(t, 0).obscureText, isTrue); + expect(field(t, 0).autocorrect, isFalse); + expect(field(t, 0).enableSuggestions, isFalse); + expect(find.byTooltip('보이기'), findsOneWidget); + expect(find.byIcon(Icons.visibility_outlined), findsOneWidget); + + await t.tap(find.byType(IconButton)); + await t.pump(); + expect(field(t, 0).obscureText, isFalse, reason: '누른 뒤 손을 떼도 보임'); + expect(find.byTooltip('가리기'), findsOneWidget); + expect(find.byIcon(Icons.visibility_off_outlined), findsOneWidget); + + await t.tap(find.byType(IconButton)); + await t.pump(); + expect(field(t, 0).obscureText, isTrue); + expect(edit(t, 0).controller.text, 'pw', reason: '보이기 · 가리기는 글을 바꾸지 않음'); + }); + + testWidgets('바깥 가리기: 눈 단추 하나가 두 칸을 함께, 단추 없는 칸은 따름', (t) async { + var hidden = true; + await show( + t, + StatefulBuilder( + builder: (ctx, set) => Column(children: [ + SecretField(controller: TextEditingController(), label: 'a', hidden: hidden, onToggle: () => set(() => hidden = !hidden)), + SecretField(controller: TextEditingController(), label: 'b', hidden: hidden), + ]), + )); + expect(find.byType(IconButton), findsOneWidget, reason: '단추는 첫 칸에만'); + expect([field(t, 0).obscureText, field(t, 1).obscureText], [true, true]); + await t.tap(find.byType(IconButton)); + await t.pump(); + expect([field(t, 0).obscureText, field(t, 1).obscureText], [false, false]); + }); + + testWidgets('오류 글 · 도움 글이 칸 아래에 보임', (t) async { + await show(t, SecretField(controller: TextEditingController(), label: '다시 넣기', helper: '없으면 비웁니다', errorText: '두 비밀번호가 다릅니다')); + expect(find.text('두 비밀번호가 다릅니다'), findsOneWidget); + }); + + for (final width in [300.0, 412.0]) { + for (final scale in [1.0, 2.0]) { + testWidgets('폭 $width · 배율 $scale: 긴 이름 · 도움 글도 넘치지 않음', (t) async { + await show( + t, + SecretField( + controller: TextEditingController(), + label: 'API key or username:password', + helper: 'If set, encrypts with AES-256. Leave empty for a plain ZIP archive file'), + width: width, + scale: scale); + expect(t.takeException(), isNull); + expect(t.getRect(find.byType(IconButton)).right, lessThanOrEqualTo(width)); + }); + } + } +} diff --git a/test/support/sftp_server.dart b/test/support/sftp_server.dart index c852208..03242c2 100644 --- a/test/support/sftp_server.dart +++ b/test/support/sftp_server.dart @@ -28,6 +28,9 @@ class SftpTestServer { String? pubkey, String keytype = 'ed25519', bool legacyOnly = false, + // 캡처 시험 (관리자 10/24): 포트 · 호스트 키를 실행마다 같게 (창의 포트 칸 · 지문 글이 흔들리지 않게). 0 이면 빈 포트 + int fixedPort = 0, + bool fixedHostKey = false, }) async { final pr = await Process.start(sftpTestPython, [ 'tool/sftp_test_server.py', @@ -38,6 +41,8 @@ class SftpTestServer { if (password != null) ...['--password', password], if (pubkey != null) ...['--pubkey', pubkey], if (legacyOnly) '--legacy-only', + if (fixedPort != 0) ...['--port', '$fixedPort'], + if (fixedHostKey) '--fixed-hostkey', ]); final lines = pr.stdout.transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream(); pr.stderr.drain(); diff --git a/tool/sftp_test_server.py b/tool/sftp_test_server.py index 37f591a..d398c15 100644 --- a/tool/sftp_test_server.py +++ b/tool/sftp_test_server.py @@ -5,6 +5,7 @@ - 첫 줄에 "PORT <번호>" 를 찍는다 (port 0 이면 빈 포트). - --legacy-only: 호스트 키 서명을 ssh-rsa (SHA-1) 만 (오래된 서버 흉내, RSA 호스트 키일 때). - 호스트 키 파일이 없으면 만든다 (--keytype ed25519 · rsa). + - --fixed-hostkey: 실행마다 같은 ed25519 호스트 키 (캡처의 지문 글이 흔들리지 않게 - 시험 전용, 파일에 남기지 않음). paramiko 3.5.1 이 필요하다 (tools\\sftp_test_py 의 venv: python -m pip install "paramiko==3.5.1"). 5.0 은 ssh-rsa (SHA-1) · SHA-1 키 교환을 빼서 --legacy-only (오래된 서버 흉내) 가 아무 방식도 내놓지 못한다. """ @@ -202,6 +203,17 @@ def _load_or_make_key(path, keytype): return paramiko.Ed25519Key(filename=path) +def _fixed_key(): + # 정한 글에서 만든 ed25519 키 - 캡처 시험이 실행마다 같은 지문을 보게 (시험 서버 전용) + import hashlib + import io + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ed25519 + pk = ed25519.Ed25519PrivateKey.from_private_bytes(hashlib.sha256(b'jj-mkvmaker sftp shot host key').digest()) + pem = pk.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, serialization.NoEncryption()) + return paramiko.Ed25519Key(file_obj=io.StringIO(pem.decode())) + + def _public_key(path): text = open(path, encoding='ascii').read().split() kind, data = text[0], text[1] @@ -218,9 +230,10 @@ def main(): ap.add_argument('--pubkey') ap.add_argument('--legacy-only', action='store_true') ap.add_argument('--port', type=int, default=0) + ap.add_argument('--fixed-hostkey', action='store_true') a = ap.parse_args() _Sftp.ROOT = os.path.abspath(a.root) - host_key = _load_or_make_key(a.hostkey, a.keytype) + host_key = _fixed_key() if a.fixed_hostkey else _load_or_make_key(a.hostkey, a.keytype) pub = _public_key(a.pubkey) if a.pubkey else None sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)