W JjField 1: 비밀 칸 6곳을 SecretField 하나로 + 캡처 흔들림 (check_shot 37·65·94 · sshkey) 을 시험 쪽에서 고정

- 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 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-24 20:19:59 +09:00
parent 2eb5f335db
commit 36069a8707
16 changed files with 249 additions and 111 deletions

View File

@ -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.", "비밀번호 · 자막 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)", "비밀번호 기억 (안전 저장소)": "Remember passwords (secure storage)",
"비밀번호 다시 넣기": "Enter password again", "비밀번호 다시 넣기": "Enter password again",
"비밀번호 보기": "Show password",
"비밀번호 숨기기": "Hide password",
"비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "Can't preview a password-protected archive - use [Extract]", "비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "Can't preview a password-protected archive - use [Extract]",
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "Some files are password-protected (you'll be asked when extracting)", "비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "Some files are password-protected (you'll be asked when extracting)",
"비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "This file is password-protected - use [Extract]", "비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "This file is password-protected - use [Extract]",

View File

@ -1609,8 +1609,6 @@
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "パスワード・字幕 API キーはバックアップされないので、もう一度入力してください。自動バックアップは 1 日に 1 回程度作成されるため、その後に変更したものは含まれていない場合があります。", "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "パスワード・字幕 API キーはバックアップされないので、もう一度入力してください。自動バックアップは 1 日に 1 回程度作成されるため、その後に変更したものは含まれていない場合があります。",
"비밀번호 기억 (안전 저장소)": "パスワードを記憶 (安全な保存領域)", "비밀번호 기억 (안전 저장소)": "パスワードを記憶 (安全な保存領域)",
"비밀번호 다시 넣기": "パスワードをもう一度入力", "비밀번호 다시 넣기": "パスワードをもう一度入力",
"비밀번호 보기": "パスワードを表示",
"비밀번호 숨기기": "パスワードを隠す",
"비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "パスワード付きアーカイブの中はプレビューできません - [展開] で展開してください", "비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "パスワード付きアーカイブの中はプレビューできません - [展開] で展開してください",
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "パスワード付きのファイルがあります (展開時に確認します)", "비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "パスワード付きのファイルがあります (展開時に確認します)",
"비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "パスワード付きのファイルです - [展開] で展開してください", "비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "パスワード付きのファイルです - [展開] で展開してください",

View File

@ -1609,8 +1609,6 @@
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "密码 · 字幕 API 密钥不会备份,请重新输入。自动备份大约每天创建一次,之后更改的内容可能缺失。", "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "密码 · 字幕 API 密钥不会备份,请重新输入。自动备份大约每天创建一次,之后更改的内容可能缺失。",
"비밀번호 기억 (안전 저장소)": "记住密码 (安全存储)", "비밀번호 기억 (안전 저장소)": "记住密码 (安全存储)",
"비밀번호 다시 넣기": "再次输入密码", "비밀번호 다시 넣기": "再次输入密码",
"비밀번호 보기": "显示密码",
"비밀번호 숨기기": "隐藏密码",
"비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "无法预览有密码的压缩包 - 请用 [解压]", "비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "无法预览有密码的压缩包 - 请用 [解压]",
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "部分文件设有密码 (解压时会询问)", "비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "部分文件设有密码 (解压时会询问)",
"비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "此文件有密码 - 请用 [解压]", "비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "此文件有密码 - 请用 [解压]",

View File

@ -49,3 +49,34 @@ void deleteTestTemp(Directory dir) {
} }
stderr.writeln('시험 임시 폴더를 지우지 못함: $path'); 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<File>()) {
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' }",
]);
}

View File

@ -34,11 +34,9 @@ void main() {
// ⑦ 밝은 화면 캡처: JJ_SHOT_THEME=light (JJ_SHOT_DIR 를 따로 두어 어두운 것과 견줌) // ⑦ 밝은 화면 캡처: JJ_SHOT_THEME=light (JJ_SHOT_DIR 를 따로 두어 어두운 것과 견줌)
if (Platform.environment['JJ_SHOT_THEME'] == 'light') JjColors.use(Brightness.light); 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'); final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots');
// 시험 폴더: 항목이 아주 많은 시스템 임시 폴더 대신 (JJ_SHOT_TMP, 없으면 시스템 임시 폴더) // 시험 폴더: 항목이 아주 많은 시스템 임시 폴더 대신 (JJ_SHOT_TMP, 없으면 시스템 임시 폴더).
Directory tempDir(String prefix) { // 관리자 지시 (10/24): 이름은 늘 같게 (경로 막대 · 공유 경로가 실행마다 달라 37 · 65 · 94 캡처가 흔들렸다) - 파일 · 폴더 시각은 [pinTestTimes]
final base = Platform.environment['JJ_SHOT_TMP']; Directory tempDir(String name) => testFixedTempDir(name, base: Platform.environment['JJ_SHOT_TMP']);
return testTempDir(prefix, base: base);
}
final key = GlobalKey(); final key = GlobalKey();
const sizes = [('narrow', Size(700, 900)), ('wide', Size(1280, 800))]; const sizes = [('narrow', Size(700, 900)), ('wide', Size(1280, 800))];
@ -103,7 +101,7 @@ void main() {
} }
testWidgets('65 · 94 · 98: 지우기 - 휴지통 · 영구 삭제 · 휴지통 없는 위치 · 오류 문구', (t) async { testWidgets('65 · 94 · 98: 지우기 - 휴지통 · 영구 삭제 · 휴지통 없는 위치 · 오류 문구', (t) async {
final tmp = tempDir('jj_shot_del_'); final tmp = tempDir('jj_shot_del_fixed');
final started = DateTime.now(); final started = DateTime.now();
// 휴지통: 기본은 가짜 (화면 흐름만), JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통 (끝에 시험 항목만 지움) // 휴지통: 기본은 가짜 (화면 흐름만), JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통 (끝에 시험 항목만 지움)
final device = TestExplorerDevice(); final device = TestExplorerDevice();
@ -113,6 +111,7 @@ void main() {
final dir = Directory(p.join(tmp.path, tag))..createSync(); 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, 'recycle_me_$tag.txt')).writeAsStringSync('x');
File(p.join(dir.path, 'keep_$tag.txt')).writeAsStringSync('x'); File(p.join(dir.path, 'keep_$tag.txt')).writeAsStringSync('x');
pinTestTimes(dir);
final c = controller(); final c = controller();
c.settings c.settings
..explorer.style = 'xplore' // 이 캡처는 X-plore 모양 (E6 부터 Windows 처음 값은 Total Commander - 이름 · 확장자가 다른 칸) ..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 unc = '\\\\localhost\\${tmp.path[0]}\$${tmp.path.substring(2)}';
final share = Directory(p.join(unc, 'share'))..createSync(); final share = Directory(p.join(unc, 'share'))..createSync();
File(p.join(share.path, 'on_share.txt')).writeAsStringSync('x'); File(p.join(share.path, 'on_share.txt')).writeAsStringSync('x');
pinTestTimes(share);
for (final (tag, size) in sizes) { for (final (tag, size) in sizes) {
final c = controller(); final c = controller();
c.settings c.settings
@ -206,10 +206,11 @@ void main() {
}); });
testWidgets('37: 경로 입력 - 폴더로 가기 · \\\\NAS 만 · 없는 경로', (t) async { testWidgets('37: 경로 입력 - 폴더로 가기 · \\\\NAS 만 · 없는 경로', (t) async {
final tmp = tempDir('jj_shot_path_'); final tmp = tempDir('jj_shot_path_fixed');
try { try {
final target = Directory(p.join(tmp.path, '가고 싶은 폴더'))..createSync(); final target = Directory(p.join(tmp.path, '가고 싶은 폴더'))..createSync();
File(p.join(target.path, 'inside.txt')).writeAsStringSync('x'); File(p.join(target.path, 'inside.txt')).writeAsStringSync('x');
pinTestTimes(tmp);
for (final (tag, size) in sizes) { for (final (tag, size) in sizes) {
final c = controller(); final c = controller();
c.settings c.settings

View File

@ -43,7 +43,8 @@ void main() {
addTearDown(t.view.reset); addTearDown(t.view.reset);
await t.runAsync(() => i18n.apply(lang, save: false)); await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', 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(); final root = Directory(p.join(tmp.path, 'root'))..createSync();
Future<String> gen(String name, String pass) async { Future<String> gen(String name, String pass) async {
final f = p.join(tmp.path, name); final f = p.join(tmp.path, name);
@ -57,7 +58,7 @@ void main() {
await t.runAsync(() async { await t.runAsync(() async {
keyA = await gen('a', 'kp'); keyA = await gen('a', 'kp');
keyB = await gen('b', ''); 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)); addTearDown(() => t.runAsync(server.stop));
final ppk = File(p.join(tmp.path, 'key.ppk')) final ppk = File(p.join(tmp.path, 'key.ppk'))

View File

@ -19,6 +19,7 @@ import 'setting_tile.dart';
import 'theme.dart'; import 'theme.dart';
import '../app/ai_upscale.dart' show deviceMemTotalMb, upscaleMemoryLines; import '../app/ai_upscale.dart' show deviceMemTotalMb, upscaleMemoryLines;
import 'notice_band.dart'; import 'notice_band.dart';
import 'secret_field.dart';
/// 기기 안에서 그리는 데 아직 받지 않은 파일 (Windows 는 실행 파일까지, TAESD 포함) - AI 그림 화면 · 받기 화면이 같은 값을 보인다 (139) /// 기기 안에서 그리는 데 아직 받지 않은 파일 (Windows 는 실행 파일까지, TAESD 포함) - AI 그림 화면 · 받기 화면이 같은 값을 보인다 (139)
List<AiFile> aiMissingFiles(AiStore store) => [ List<AiFile> aiMissingFiles(AiStore store) => [
@ -476,7 +477,6 @@ Future<void> editAiService(BuildContext context, AppController c, {AiService? ol
final model = TextEditingController(text: old?.model ?? ''); final model = TextEditingController(text: old?.model ?? '');
final key = TextEditingController(text: old?.apiKey ?? ''); final key = TextEditingController(text: old?.apiKey ?? '');
var kind = old?.kind ?? 'a1111'; var kind = old?.kind ?? 'a1111';
var hide = true;
final saved = await showDialog<AiService>( final saved = await showDialog<AiService>(
context: context, context: context,
builder: (ctx) => StatefulBuilder( builder: (ctx) => StatefulBuilder(
@ -517,17 +517,11 @@ Future<void> editAiService(BuildContext context, AppController c, {AiService? ol
helperMaxLines: 3, helperMaxLines: 3,
), ),
), ),
TextField( // 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다
controller: key, SecretField(
obscureText: hide, controller: key,
decoration: InputDecoration( label: kind == 'a1111' ? tr('API 키 또는 아이디:비밀번호') : tr('API 키'),
// 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다 helper: tr('넣지 않아도 됩니다')),
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)),
),
),
]), ]),
), ),
actions: [ actions: [

View File

@ -13,6 +13,7 @@ import '../core/vfs.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'app_actions.dart' show isCompact; import 'app_actions.dart' show isCompact;
import 'theme.dart'; import 'theme.dart';
import 'secret_field.dart';
/// 86: 동봉한 7-Zip. 없으면 null /// 86: 동봉한 7-Zip. 없으면 null
/// - Windows: 실행 파일 옆 7zip\7z.exe (개발 중에는 third_party 의 것) /// - Windows: 실행 파일 옆 7zip\7z.exe (개발 중에는 third_party 의 것)
@ -354,37 +355,24 @@ Future<String?> createZipFlow(BuildContext context, AppController c, List<String
Text(trf('만들 파일: {0}', [p.basename(target())]), style: TextStyle(fontSize: 12, color: JjColors.textDim)), Text(trf('만들 파일: {0}', [p.basename(target())]), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
Text(tr('링크 · junction 은 넣지 않습니다'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), Text(tr('링크 · junction 은 넣지 않습니다'), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 10), const SizedBox(height: 10),
TextField( // 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다 - "선택" 은 아래 도움 글 (비우면 보통 ZIP) 이 알린다
SecretField(
controller: pass, controller: pass,
obscureText: !showPass, label: tr('비밀번호'),
autocorrect: false, helper: tr('넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP'),
enableSuggestions: false, isDense: true,
decoration: InputDecoration( hidden: !showPass,
// 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다 - "선택" 은 아래 도움 글 (비우면 보통 ZIP) 이 알린다 onToggle: () => set(() => showPass = !showPass),
labelText: tr('비밀번호'),
helperText: tr('넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP'),
helperMaxLines: 3,
isDense: true,
suffixIcon: IconButton(
tooltip: showPass ? tr('비밀번호 숨기기') : tr('비밀번호 보기'),
icon: Icon(showPass ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 18),
onPressed: () => set(() => showPass = !showPass),
),
),
onChanged: (_) => set(() {}), onChanged: (_) => set(() {}),
), ),
if (pass.text.isNotEmpty) ...[ if (pass.text.isNotEmpty) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
TextField( SecretField(
controller: pass2, controller: pass2,
obscureText: !showPass, label: tr('비밀번호 다시 넣기'),
autocorrect: false, isDense: true,
enableSuggestions: false, hidden: !showPass,
decoration: InputDecoration( errorText: pass2.text.isNotEmpty && pass2.text != pass.text ? tr('두 비밀번호가 다릅니다') : null,
labelText: tr('비밀번호 다시 넣기'),
isDense: true,
errorText: pass2.text.isNotEmpty && pass2.text != pass.text ? tr('두 비밀번호가 다릅니다') : null,
),
onChanged: (_) => set(() {}), onChanged: (_) => set(() {}),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),

View File

@ -10,6 +10,7 @@ import 'setting_tile.dart';
import 'theme.dart'; import 'theme.dart';
import 'webdav_settings.dart' show davServerUsers; import 'webdav_settings.dart' show davServerUsers;
import 'notice_band.dart'; import 'notice_band.dart';
import 'secret_field.dart';
/// l: 환경 설정 > 파일 탐색기 > FTP · FTPS: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인). SFTP 와 같은 틀. /// l: 환경 설정 > 파일 탐색기 > FTP · FTPS: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인). SFTP 와 같은 틀.
/// 서버마다 파일 탐색기 · Rsync 화면 위쪽 (WebDAV 옆) 에 탭이 생긴다. 평문 FTP 는 목록 · 탭 · 창에 "암호화 안 됨" 을 늘 보인다 /// 서버마다 파일 탐색기 · Rsync 화면 위쪽 (WebDAV 옆) 에 탭이 생긴다. 평문 FTP 는 목록 · 탭 · 창에 "암호화 안 됨" 을 늘 보인다
@ -139,7 +140,6 @@ Future<FtpServer?> editFtpServer(BuildContext context, AppController c, {FtpServ
final root = TextEditingController(text: old?.root ?? ''); final root = TextEditingController(text: old?.root ?? '');
var security = old?.security ?? 'explicit'; var security = old?.security ?? 'explicit';
var fingerprint = old?.certFingerprint ?? ''; var fingerprint = old?.certFingerprint ?? '';
var hide = true;
String? result; String? result;
var passed = false; var passed = false;
var testing = false; var testing = false;
@ -246,18 +246,7 @@ Future<FtpServer?> editFtpServer(BuildContext context, AppController c, {FtpServ
if (security == 'none') if (security == 'none')
NoticeBand(icon: Icons.lock_open, color: JjColors.danger, margin: EdgeInsets.zero, title: tr('평문 FTP: 암호화 안 됨'), body: tr('평문 FTP 는 아이디 · 비밀번호 · 파일이 암호화되지 않은 채 오갑니다. 같은 망의 다른 사람이 볼 수 있습니다. 서버가 FTPS 를 못 할 때만, 믿을 수 있는 집 안 망에서만 쓰세요.')), 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: user, decoration: InputDecoration(labelText: tr('아이디 (비우면 익명)'))),
TextField( SecretField(controller: pass, label: tr('비밀번호')),
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),
),
),
),
TextField( TextField(
controller: root, controller: root,
decoration: InputDecoration(labelText: tr('시작 폴더'), helperText: tr('비우면 로그인한 곳 · 예: /volume1/video · 탭의 맨 위가 됩니다'), helperMaxLines: 3), decoration: InputDecoration(labelText: tr('시작 폴더'), helperText: tr('비우면 로그인한 곳 · 예: /volume1/video · 탭의 맨 위가 됩니다'), helperMaxLines: 3),

71
lib/ui/secret_field.dart Normal file
View File

@ -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<String>? onChanged;
/// 바깥에서 가리기를 정할 때 (눈 단추 하나로 칸 여럿 - 비밀번호와 확인 칸 등). null 이면 칸이 스스로 든다
final bool? hidden;
/// [hidden] 을 바깥에서 줄 때의 눈 단추 (null 이면 이 칸에는 단추 없음)
final VoidCallback? onToggle;
@override
State<SecretField> createState() => _SecretFieldState();
}
class _SecretFieldState extends State<SecretField> {
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,
),
),
);
}
}

View File

@ -11,6 +11,7 @@ import 'confirm.dart';
import 'setting_tile.dart'; import 'setting_tile.dart';
import 'theme.dart'; import 'theme.dart';
import 'webdav_settings.dart' show davServerUsers; import 'webdav_settings.dart' show davServerUsers;
import 'secret_field.dart';
/// 88: 환경 설정 > 파일 탐색기 > SFTP: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인). /// 88: 환경 설정 > 파일 탐색기 > SFTP: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인).
/// 서버마다 파일 탐색기 · Rsync 화면 위쪽 (WebDAV 옆) 에 탭이 생긴다. /// 서버마다 파일 탐색기 · Rsync 화면 위쪽 (WebDAV 옆) 에 탭이 생긴다.
@ -274,21 +275,14 @@ Future<SftpServer?> editSftpServer(BuildContext context, AppController c, {SftpS
), ),
]), ]),
TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디'))), TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디'))),
TextField( // 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다.
controller: pass, // 눈 단추 하나가 비밀번호 · 개인 키 암호를 함께 보이고 가린다
obscureText: hide, SecretField(
decoration: InputDecoration( controller: pass,
// 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다 label: tr('비밀번호'),
labelText: tr('비밀번호'), helper: tr('개인 키만 쓰면 비웁니다'),
helperText: tr('개인 키만 쓰면 비웁니다'), hidden: hide,
helperMaxLines: 3, onToggle: () => set(() => hide = !hide)),
suffixIcon: IconButton(
tooltip: hide ? tr('보이기') : tr('가리기'),
icon: Icon(hide ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18),
onPressed: () => set(() => hide = !hide),
),
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
OutlinedButton.icon( OutlinedButton.icon(
@ -302,11 +296,7 @@ Future<SftpServer?> editSftpServer(BuildContext context, AppController c, {SftpS
], ],
]), ]),
if (key.isNotEmpty) if (key.isNotEmpty)
TextField( SecretField(controller: passphrase, label: tr('개인 키 암호'), helper: tr('없으면 비웁니다'), hidden: hide),
controller: passphrase,
obscureText: hide,
decoration: InputDecoration(labelText: tr('개인 키 암호'), helperText: tr('없으면 비웁니다'), helperMaxLines: 3),
),
TextField( TextField(
controller: root, controller: root,
decoration: InputDecoration(labelText: tr('시작 폴더'), helperText: tr('비우면 홈 폴더 · 예: /volume1/video · 탭의 맨 위가 됩니다'), helperMaxLines: 3), decoration: InputDecoration(labelText: tr('시작 폴더'), helperText: tr('비우면 홈 폴더 · 예: /volume1/video · 탭의 맨 위가 됩니다'), helperMaxLines: 3),

View File

@ -10,6 +10,7 @@ import 'confirm.dart';
import 'setting_tile.dart'; import 'setting_tile.dart';
import 'theme.dart'; import 'theme.dart';
import 'webdav_settings.dart' show davServerUsers; import 'webdav_settings.dart' show davServerUsers;
import 'secret_field.dart';
/// 88: 환경 설정 > 파일 탐색기 > SMB (Android 만): 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인). /// 88: 환경 설정 > 파일 탐색기 > SMB (Android 만): 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인).
/// Windows 는 운영체제가 \\서버\공유 를 연다 (경로 입력 · 네트워크 드라이브) - 사용자 결정 88-1. /// Windows 는 운영체제가 \\서버\공유 를 연다 (경로 입력 · 네트워크 드라이브) - 사용자 결정 88-1.
@ -124,7 +125,6 @@ Future<SmbServer?> editSmbServer(BuildContext context, AppController c, {SmbServ
final user = TextEditingController(text: old?.user ?? ''); final user = TextEditingController(text: old?.user ?? '');
final domain = TextEditingController(text: old?.domain ?? ''); final domain = TextEditingController(text: old?.domain ?? '');
final pass = TextEditingController(text: old?.password ?? ''); final pass = TextEditingController(text: old?.password ?? '');
var hide = true;
String? result; String? result;
String? warning; String? warning;
var passed = false; var passed = false;
@ -229,18 +229,7 @@ Future<SmbServer?> editSmbServer(BuildContext context, AppController c, {SmbServ
), ),
TextField(controller: root, decoration: InputDecoration(labelText: tr('시작 폴더'), helperText: tr('공유 안의 폴더 · 비우면 공유의 맨 위'), helperMaxLines: 3)), TextField(controller: root, decoration: InputDecoration(labelText: tr('시작 폴더'), helperText: tr('공유 안의 폴더 · 비우면 공유의 맨 위'), helperMaxLines: 3)),
TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디 (비우면 게스트)'))), TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디 (비우면 게스트)'))),
TextField( SecretField(controller: pass, label: tr('비밀번호')),
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),
),
),
),
TextField(controller: domain, decoration: InputDecoration(labelText: tr('도메인 · 작업 그룹'), helperText: tr('보통 비워 둡니다'), helperMaxLines: 3)), TextField(controller: domain, decoration: InputDecoration(labelText: tr('도메인 · 작업 그룹'), helperText: tr('보통 비워 둡니다'), helperMaxLines: 3)),
const SizedBox(height: 8), const SizedBox(height: 8),
if (testing) const LinearProgressIndicator(), if (testing) const LinearProgressIndicator(),

View File

@ -10,6 +10,7 @@ import 'confirm.dart';
import 'file_error.dart'; import 'file_error.dart';
import 'theme.dart'; import 'theme.dart';
import 'setting_tile.dart'; import 'setting_tile.dart';
import 'secret_field.dart';
/// 환경 설정 > 파일 탐색기 > WebDAV: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인). /// 환경 설정 > 파일 탐색기 > WebDAV: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인).
/// 서버마다 파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆에 탭이 생긴다. /// 서버마다 파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆에 탭이 생긴다.
@ -106,7 +107,6 @@ Future<DavServer?> editDavServer(BuildContext context, AppController c, {DavServ
final user = TextEditingController(text: old?.user ?? ''); final user = TextEditingController(text: old?.user ?? '');
final pass = TextEditingController(text: old?.password ?? ''); final pass = TextEditingController(text: old?.password ?? '');
var insecure = old?.insecure ?? false; var insecure = old?.insecure ?? false;
var hide = true;
String? result; // 연결 확인 결과 String? result; // 연결 확인 결과
var toHttps = false; // 서버가 https 로 옮기라고 함 → [https 로 바꾸기] var toHttps = false; // 서버가 https 로 옮기라고 함 → [https 로 바꾸기]
var passed = false; var passed = false;
@ -185,17 +185,7 @@ Future<DavServer?> editDavServer(BuildContext context, AppController c, {DavServ
]), ]),
), ),
TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디'))), TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디'))),
TextField( SecretField(controller: pass, label: tr('비밀번호')),
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),
),
),
),
CheckboxListTile( CheckboxListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
value: insecure, value: insecure,

View File

@ -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<TextField>(find.byType(TextField).at(i));
EditableText edit(WidgetTester t, int i) => t.widget<EditableText>(find.byType(EditableText).at(i));
Future<void> 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));
});
}
}
}

View File

@ -28,6 +28,9 @@ class SftpTestServer {
String? pubkey, String? pubkey,
String keytype = 'ed25519', String keytype = 'ed25519',
bool legacyOnly = false, bool legacyOnly = false,
// 캡처 시험 (관리자 10/24): 포트 · 호스트 키를 실행마다 같게 (창의 포트 칸 · 지문 글이 흔들리지 않게). 0 이면 빈 포트
int fixedPort = 0,
bool fixedHostKey = false,
}) async { }) async {
final pr = await Process.start(sftpTestPython, [ final pr = await Process.start(sftpTestPython, [
'tool/sftp_test_server.py', 'tool/sftp_test_server.py',
@ -38,6 +41,8 @@ class SftpTestServer {
if (password != null) ...['--password', password], if (password != null) ...['--password', password],
if (pubkey != null) ...['--pubkey', pubkey], if (pubkey != null) ...['--pubkey', pubkey],
if (legacyOnly) '--legacy-only', if (legacyOnly) '--legacy-only',
if (fixedPort != 0) ...['--port', '$fixedPort'],
if (fixedHostKey) '--fixed-hostkey',
]); ]);
final lines = pr.stdout.transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream(); final lines = pr.stdout.transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream();
pr.stderr.drain<void>(); pr.stderr.drain<void>();

View File

@ -5,6 +5,7 @@
- 첫 줄에 "PORT <번호>" 를 찍는다 (port 0 이면 빈 포트). - 첫 줄에 "PORT <번호>" 를 찍는다 (port 0 이면 빈 포트).
- --legacy-only: 호스트 키 서명을 ssh-rsa (SHA-1) 만 (오래된 서버 흉내, RSA 호스트 키일 때). - --legacy-only: 호스트 키 서명을 ssh-rsa (SHA-1) 만 (오래된 서버 흉내, RSA 호스트 키일 때).
- 호스트 키 파일이 없으면 만든다 (--keytype ed25519 · rsa). - 호스트 키 파일이 없으면 만든다 (--keytype ed25519 · rsa).
- --fixed-hostkey: 실행마다 같은 ed25519 호스트 키 (캡처의 지문 글이 흔들리지 않게 - 시험 전용, 파일에 남기지 않음).
paramiko 3.5.1 이 필요하다 (tools\\sftp_test_py 의 venv: python -m pip install "paramiko==3.5.1"). 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 (오래된 서버 흉내) 가 아무 방식도 내놓지 못한다. 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) 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): def _public_key(path):
text = open(path, encoding='ascii').read().split() text = open(path, encoding='ascii').read().split()
kind, data = text[0], text[1] kind, data = text[0], text[1]
@ -218,9 +230,10 @@ def main():
ap.add_argument('--pubkey') ap.add_argument('--pubkey')
ap.add_argument('--legacy-only', action='store_true') ap.add_argument('--legacy-only', action='store_true')
ap.add_argument('--port', type=int, default=0) ap.add_argument('--port', type=int, default=0)
ap.add_argument('--fixed-hostkey', action='store_true')
a = ap.parse_args() a = ap.parse_args()
_Sftp.ROOT = os.path.abspath(a.root) _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 pub = _public_key(a.pubkey) if a.pubkey else None
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)